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::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::{ToLowerCamelCase, ToUpperCamelCase};
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
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        alef_config: &AlefConfig,
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(|| alef_config.crate_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(|| alef_config.crate_config.name.clone());
62
63        // Resolve Java package info for the dependency.
64        let java_group_id = alef_config.java_group_id();
65        let pkg_version = alef_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.spikard` becomes `dev/spikard`, 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        let field_resolver = FieldResolver::new(
86            &e2e_config.fields,
87            &e2e_config.fields_optional,
88            &e2e_config.result_fields,
89            &e2e_config.fields_array,
90        );
91
92        for group in groups {
93            let active: Vec<&Fixture> = group
94                .fixtures
95                .iter()
96                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
97                .collect();
98
99            if active.is_empty() {
100                continue;
101            }
102
103            let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
104            let content = render_test_file(
105                &group.category,
106                &active,
107                &class_name,
108                &function_name,
109                &java_group_id,
110                result_var,
111                &e2e_config.call.args,
112                options_type.as_deref(),
113                &field_resolver,
114                result_is_simple,
115                &e2e_config.fields_enum,
116                e2e_config,
117            );
118            files.push(GeneratedFile {
119                path: test_base.join(class_file_name),
120                content,
121                generated_header: true,
122            });
123        }
124
125        Ok(files)
126    }
127
128    fn language_name(&self) -> &'static str {
129        "java"
130    }
131}
132
133// ---------------------------------------------------------------------------
134// Rendering
135// ---------------------------------------------------------------------------
136
137fn render_pom_xml(
138    pkg_name: &str,
139    java_group_id: &str,
140    pkg_version: &str,
141    dep_mode: crate::config::DependencyMode,
142) -> String {
143    // pkg_name may be in "groupId:artifactId" Maven format; split accordingly.
144    let (dep_group_id, dep_artifact_id) = if let Some((g, a)) = pkg_name.split_once(':') {
145        (g, a)
146    } else {
147        (java_group_id, pkg_name)
148    };
149    let artifact_id = format!("{dep_artifact_id}-e2e-java");
150    let dep_block = match dep_mode {
151        crate::config::DependencyMode::Registry => {
152            format!(
153                r#"        <dependency>
154            <groupId>{dep_group_id}</groupId>
155            <artifactId>{dep_artifact_id}</artifactId>
156            <version>{pkg_version}</version>
157        </dependency>"#
158            )
159        }
160        crate::config::DependencyMode::Local => {
161            format!(
162                r#"        <dependency>
163            <groupId>{dep_group_id}</groupId>
164            <artifactId>{dep_artifact_id}</artifactId>
165            <version>{pkg_version}</version>
166            <scope>system</scope>
167            <systemPath>${{project.basedir}}/../../packages/java/target/{dep_artifact_id}-{pkg_version}.jar</systemPath>
168        </dependency>"#
169            )
170        }
171    };
172    format!(
173        r#"<?xml version="1.0" encoding="UTF-8"?>
174<project xmlns="http://maven.apache.org/POM/4.0.0"
175         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
176         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
177    <modelVersion>4.0.0</modelVersion>
178
179    <groupId>{java_group_id}</groupId>
180    <artifactId>{artifact_id}</artifactId>
181    <version>0.1.0</version>
182
183    <properties>
184        <maven.compiler.source>25</maven.compiler.source>
185        <maven.compiler.target>25</maven.compiler.target>
186        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
187        <junit.version>{junit}</junit.version>
188    </properties>
189
190    <dependencies>
191{dep_block}
192        <dependency>
193            <groupId>com.fasterxml.jackson.core</groupId>
194            <artifactId>jackson-databind</artifactId>
195            <version>{jackson}</version>
196        </dependency>
197        <dependency>
198            <groupId>com.fasterxml.jackson.datatype</groupId>
199            <artifactId>jackson-datatype-jdk8</artifactId>
200            <version>{jackson}</version>
201        </dependency>
202        <dependency>
203            <groupId>org.junit.jupiter</groupId>
204            <artifactId>junit-jupiter</artifactId>
205            <version>${{junit.version}}</version>
206            <scope>test</scope>
207        </dependency>
208    </dependencies>
209
210    <build>
211        <plugins>
212            <plugin>
213                <groupId>org.codehaus.mojo</groupId>
214                <artifactId>build-helper-maven-plugin</artifactId>
215                <version>{build_helper}</version>
216                <executions>
217                    <execution>
218                        <id>add-test-source</id>
219                        <phase>generate-test-sources</phase>
220                        <goals>
221                            <goal>add-test-source</goal>
222                        </goals>
223                        <configuration>
224                            <sources>
225                                <source>src/test/java</source>
226                            </sources>
227                        </configuration>
228                    </execution>
229                </executions>
230            </plugin>
231            <plugin>
232                <groupId>org.apache.maven.plugins</groupId>
233                <artifactId>maven-surefire-plugin</artifactId>
234                <version>{maven_surefire}</version>
235                <configuration>
236                    <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=${{project.basedir}}/../../target/release</argLine>
237                    <workingDirectory>${{project.basedir}}/../../test_documents</workingDirectory>
238                </configuration>
239            </plugin>
240        </plugins>
241    </build>
242</project>
243"#,
244        junit = tv::maven::JUNIT,
245        jackson = tv::maven::JACKSON_E2E,
246        build_helper = tv::maven::BUILD_HELPER_MAVEN_PLUGIN,
247        maven_surefire = tv::maven::MAVEN_SUREFIRE_PLUGIN_E2E,
248    )
249}
250
251#[allow(clippy::too_many_arguments)]
252fn render_test_file(
253    category: &str,
254    fixtures: &[&Fixture],
255    class_name: &str,
256    function_name: &str,
257    java_group_id: &str,
258    result_var: &str,
259    args: &[crate::config::ArgMapping],
260    options_type: Option<&str>,
261    field_resolver: &FieldResolver,
262    result_is_simple: bool,
263    enum_fields: &HashSet<String>,
264    e2e_config: &E2eConfig,
265) -> String {
266    let mut out = String::new();
267    out.push_str(&hash::header(CommentStyle::DoubleSlash));
268    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
269
270    // If the class_name is fully qualified (contains '.'), import it and use
271    // only the simple name for method calls.  Otherwise use it as-is.
272    let (import_path, simple_class) = if class_name.contains('.') {
273        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
274        (class_name, simple)
275    } else {
276        ("", class_name)
277    };
278
279    let _ = writeln!(out, "package {java_group_id}.e2e;");
280    let _ = writeln!(out);
281
282    // Check if any fixture (with its resolved call) will emit MAPPER usage.
283    // This covers: non-null json_object with options_type, optional null json_object with
284    // options_type (MAPPER default), and handle args with non-null config.
285    let lang_for_om = "java";
286    let needs_object_mapper_for_options = fixtures.iter().any(|f| {
287        let call_cfg = e2e_config.resolve_call(f.call.as_deref());
288        let eff_opts = call_cfg
289            .overrides
290            .get(lang_for_om)
291            .and_then(|o| o.options_type.as_deref())
292            .or(options_type);
293        if eff_opts.is_none() {
294            return false;
295        }
296        call_cfg.args.iter().any(|arg| {
297            if arg.arg_type != "json_object" {
298                return false;
299            }
300            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
301            let val = f.input.get(field);
302            // Needs MAPPER for: non-null non-array value (MAPPER.readValue) OR
303            // optional null value (MAPPER.readValue("{}", T.class) default).
304            match val {
305                None | Some(serde_json::Value::Null) => arg.optional, // MAPPER default for optional null
306                Some(v) => !v.is_array(),                             // MAPPER.readValue for non-array objects
307            }
308        })
309    });
310    // Also need ObjectMapper when a handle arg has a non-null config.
311    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
312        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
313            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
314            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
315        })
316    });
317    // HTTP fixtures always need ObjectMapper for JSON body comparison.
318    let has_http_fixtures = fixtures.iter().any(|f| f.http.is_some());
319    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
320
321    // Collect all options_type values used (class-level + per-fixture call overrides).
322    let mut all_options_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
323    if let Some(t) = options_type {
324        all_options_types.insert(t.to_string());
325    }
326    for f in fixtures.iter() {
327        let call_cfg = e2e_config.resolve_call(f.call.as_deref());
328        if let Some(ov) = call_cfg.overrides.get(lang_for_om) {
329            if let Some(t) = &ov.options_type {
330                all_options_types.insert(t.clone());
331            }
332        }
333    }
334
335    let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
336    let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
337    if !import_path.is_empty() {
338        let _ = writeln!(out, "import {import_path};");
339    }
340    if needs_object_mapper {
341        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
342        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
343    }
344    // Import all options types used across fixtures.
345    if needs_object_mapper && !all_options_types.is_empty() {
346        let opts_pkg = if !import_path.is_empty() {
347            import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("")
348        } else {
349            ""
350        };
351        for opts_type in &all_options_types {
352            let qualified = if opts_pkg.is_empty() {
353                opts_type.clone()
354            } else {
355                format!("{opts_pkg}.{opts_type}")
356            };
357            let _ = writeln!(out, "import {qualified};");
358        }
359    }
360    // Import CrawlConfig when handle args need JSON deserialization.
361    if needs_object_mapper_for_handle && !import_path.is_empty() {
362        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
363        let _ = writeln!(out, "import {pkg}.CrawlConfig;");
364    }
365    let _ = writeln!(out);
366
367    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
368    let _ = writeln!(out, "class {test_class_name} {{");
369
370    if needs_object_mapper {
371        let _ = writeln!(out);
372        let _ = writeln!(
373            out,
374            "    private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
375        );
376    }
377
378    for fixture in fixtures {
379        render_test_method(
380            &mut out,
381            fixture,
382            simple_class,
383            function_name,
384            result_var,
385            args,
386            options_type,
387            field_resolver,
388            result_is_simple,
389            enum_fields,
390            e2e_config,
391        );
392        let _ = writeln!(out);
393    }
394
395    let _ = writeln!(out, "}}");
396    out
397}
398
399/// Render an HTTP server test method using java.net.http.HttpClient against MOCK_SERVER_URL.
400///
401/// The mock server registers each fixture at `/fixtures/<fixture_id>` and returns the
402/// pre-canned response. Tests send the correct HTTP method and headers to that endpoint.
403fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
404    let method_name = fixture.id.to_upper_camel_case();
405    let description = &fixture.description;
406    let request = &http.request;
407    let expected = &http.expected_response;
408    let method = request.method.to_uppercase();
409    let fixture_id = &fixture.id;
410    let expected_status = expected.status_code;
411
412    // Skip tests that expect a 101 Switching Protocols response — Java's HttpClient
413    // cannot handle protocol-switch responses and throws an EOFException.
414    if expected_status == 101 {
415        let _ = writeln!(out, "    @Test");
416        let _ = writeln!(out, "    void test{method_name}() {{");
417        let _ = writeln!(out, "        // {description}");
418        let _ = writeln!(
419            out,
420            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\");"
421        );
422        let _ = writeln!(out, "    }}");
423        return;
424    }
425
426    let _ = writeln!(out, "    @Test");
427    let _ = writeln!(out, "    void test{method_name}() throws Exception {{");
428    let _ = writeln!(out, "        // {description}");
429    let _ = writeln!(out, "        String baseUrl = System.getenv(\"MOCK_SERVER_URL\");",);
430    let _ = writeln!(out, "        if (baseUrl == null) baseUrl = \"http://localhost:8080\";");
431
432    // The mock server serves each fixture at /fixtures/<fixture_id>.
433    // We send the correct HTTP method and headers to that endpoint.
434    let _ = writeln!(
435        out,
436        "        java.net.URI uri = java.net.URI.create(baseUrl + \"/fixtures/{fixture_id}\");"
437    );
438
439    // Build request.
440    let body_publisher = if let Some(body) = &request.body {
441        let json = serde_json::to_string(body).unwrap_or_default();
442        let escaped = escape_java(&json);
443        format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
444    } else {
445        "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
446    };
447
448    let _ = writeln!(out, "        var builder = java.net.http.HttpRequest.newBuilder(uri)");
449    let _ = writeln!(out, "            .method(\"{method}\", {body_publisher});");
450
451    // Java's HttpClient restricts certain headers that cannot be set programmatically.
452    const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
453
454    // Add headers.
455    let content_type = request.content_type.as_deref().unwrap_or("application/json");
456    if request.body.is_some() {
457        let _ = writeln!(
458            out,
459            "        builder = builder.header(\"Content-Type\", \"{content_type}\");"
460        );
461    }
462    for (name, value) in &request.headers {
463        // Skip restricted headers — Java's HttpClient throws IllegalArgumentException for these.
464        if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
465            continue;
466        }
467        let escaped_name = escape_java(name);
468        let escaped_value = escape_java(value);
469        let _ = writeln!(
470            out,
471            "        builder = builder.header(\"{escaped_name}\", \"{escaped_value}\");"
472        );
473    }
474
475    // Add cookies as Cookie header.
476    if !request.cookies.is_empty() {
477        let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
478        let cookie_header = escape_java(&cookie_str.join("; "));
479        let _ = writeln!(
480            out,
481            "        builder = builder.header(\"Cookie\", \"{cookie_header}\");"
482        );
483    }
484
485    let _ = writeln!(out, "        var response = java.net.http.HttpClient.newHttpClient()");
486    let _ = writeln!(
487        out,
488        "            .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString());"
489    );
490
491    // Assert status code.
492    let _ = writeln!(
493        out,
494        "        assertEquals({expected_status}, response.statusCode(), \"status code mismatch\");"
495    );
496
497    // Assert body if expected.
498    if let Some(expected_body) = &expected.body {
499        match expected_body {
500            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
501                let json_str = serde_json::to_string(expected_body).unwrap_or_default();
502                let escaped = escape_java(&json_str);
503                let _ = writeln!(out, "        var bodyJson = MAPPER.readTree(response.body());");
504                let _ = writeln!(out, "        var expectedJson = MAPPER.readTree(\"{escaped}\");");
505                let _ = writeln!(out, "        assertEquals(expectedJson, bodyJson, \"body mismatch\");");
506            }
507            serde_json::Value::String(s) => {
508                let escaped = escape_java(s);
509                let _ = writeln!(
510                    out,
511                    "        assertEquals(\"{escaped}\", response.body().trim(), \"body mismatch\");"
512                );
513            }
514            other => {
515                let escaped = escape_java(&other.to_string());
516                let _ = writeln!(
517                    out,
518                    "        assertEquals(\"{escaped}\", response.body().trim(), \"body mismatch\");"
519                );
520            }
521        }
522    }
523
524    // Assert response headers if specified (skip special tokens and non-applicable headers).
525    for (name, value) in &expected.headers {
526        if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
527            // Skip special-token assertions for now.
528            continue;
529        }
530        // content-encoding is set by the real spikard server (compression middleware)
531        // but the mock server doesn't compress response bodies, so skip this assertion.
532        if name.to_lowercase() == "content-encoding" {
533            continue;
534        }
535        let escaped_name = escape_java(name);
536        let escaped_value = escape_java(value);
537        let _ = writeln!(
538            out,
539            "        assertTrue(response.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\");"
540        );
541    }
542
543    let _ = writeln!(out, "    }}");
544}
545
546#[allow(clippy::too_many_arguments)]
547fn render_test_method(
548    out: &mut String,
549    fixture: &Fixture,
550    class_name: &str,
551    _function_name: &str,
552    _result_var: &str,
553    _args: &[crate::config::ArgMapping],
554    options_type: Option<&str>,
555    field_resolver: &FieldResolver,
556    result_is_simple: bool,
557    enum_fields: &HashSet<String>,
558    e2e_config: &E2eConfig,
559) {
560    // Delegate HTTP fixtures to the HTTP-specific renderer.
561    if let Some(http) = &fixture.http {
562        render_http_test_method(out, fixture, http);
563        return;
564    }
565
566    // Resolve per-fixture call config (supports named calls via fixture.call field).
567    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
568    let lang = "java";
569    let call_overrides = call_config.overrides.get(lang);
570    let effective_function_name = call_overrides
571        .and_then(|o| o.function.as_ref())
572        .cloned()
573        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
574    let effective_result_var = &call_config.result_var;
575    let effective_args = &call_config.args;
576    let function_name = effective_function_name.as_str();
577    let result_var = effective_result_var.as_str();
578    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
579
580    let method_name = fixture.id.to_upper_camel_case();
581    let description = &fixture.description;
582    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
583
584    // Emit a compilable stub for non-HTTP fixtures that have no call override.
585    if call_overrides.is_none() {
586        let _ = writeln!(out, "    @Test");
587        let _ = writeln!(out, "    void test{method_name}() {{");
588        let _ = writeln!(out, "        // {description}");
589        let _ = writeln!(
590            out,
591            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"TODO: implement Java e2e test for fixture '{}'\");",
592            fixture.id
593        );
594        let _ = writeln!(out, "    }}");
595        return;
596    }
597
598    // Resolve per-fixture options_type: prefer the java call override, fall back to class-level.
599    let effective_options_type: Option<String> = call_overrides
600        .and_then(|o| o.options_type.clone())
601        .or_else(|| options_type.map(|s| s.to_string()));
602    let effective_options_type = effective_options_type.as_deref();
603
604    // Resolve per-fixture result_is_simple and result_is_bytes from the call override.
605    let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
606    let effective_result_is_bytes = call_overrides.is_some_and(|o| o.result_is_bytes);
607
608    // Check if this test needs ObjectMapper deserialization for json_object args.
609    // Strip "input." prefix when looking up field in fixture.input.
610    let needs_deser = effective_options_type.is_some()
611        && args.iter().any(|arg| {
612            if arg.arg_type != "json_object" {
613                return false;
614            }
615            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
616            fixture.input.get(field).is_some_and(|v| !v.is_null() && !v.is_array())
617        });
618
619    // Always add throws Exception since the convert method may throw checked exceptions.
620    let throws_clause = " throws Exception";
621
622    let _ = writeln!(out, "    @Test");
623    let _ = writeln!(out, "    void test{method_name}(){throws_clause} {{");
624    let _ = writeln!(out, "        // {description}");
625
626    // Emit ObjectMapper deserialization bindings for json_object args.
627    if let (true, Some(opts_type)) = (needs_deser, effective_options_type) {
628        for arg in args {
629            if arg.arg_type == "json_object" {
630                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
631                if let Some(val) = fixture.input.get(field) {
632                    if !val.is_null() && !val.is_array() {
633                        // Fixture keys are camelCase; the Java record uses
634                        // @JsonProperty("snake_case") annotations. Normalize keys so Jackson
635                        // can deserialize them correctly.
636                        let normalized = super::normalize_json_keys_to_snake_case(val);
637                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
638                        let var_name = &arg.name;
639                        let _ = writeln!(
640                            out,
641                            "        var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
642                            escape_java(&json_str)
643                        );
644                    }
645                }
646            }
647        }
648    }
649
650    let (mut setup_lines, args_str) =
651        build_args_and_setup(&fixture.input, args, class_name, effective_options_type, &fixture.id);
652
653    // Build visitor if present and add to setup
654    let mut visitor_arg = String::new();
655    if let Some(visitor_spec) = &fixture.visitor {
656        visitor_arg = build_java_visitor(&mut setup_lines, visitor_spec, class_name);
657    }
658
659    for line in &setup_lines {
660        let _ = writeln!(out, "        {line}");
661    }
662
663    let final_args = if visitor_arg.is_empty() {
664        args_str
665    } else {
666        format!("{args_str}, {visitor_arg}")
667    };
668
669    if expects_error {
670        let _ = writeln!(
671            out,
672            "        assertThrows(Exception.class, () -> {class_name}.{function_name}({final_args}));"
673        );
674        let _ = writeln!(out, "    }}");
675        return;
676    }
677
678    let _ = writeln!(
679        out,
680        "        var {result_var} = {class_name}.{function_name}({final_args});"
681    );
682
683    // Emit a `source` variable for run_query assertions that need the raw bytes.
684    let needs_source_var = fixture
685        .assertions
686        .iter()
687        .any(|a| a.assertion_type == "method_result" && a.method.as_deref() == Some("run_query"));
688    if needs_source_var {
689        // Find the source_code arg to emit a `source` binding.
690        if let Some(source_arg) = args.iter().find(|a| a.field == "source_code") {
691            let field = source_arg.field.strip_prefix("input.").unwrap_or(&source_arg.field);
692            if let Some(val) = fixture.input.get(field) {
693                let java_val = json_to_java(val);
694                let _ = writeln!(out, "        var source = {java_val}.getBytes();");
695            }
696        }
697    }
698
699    for assertion in &fixture.assertions {
700        render_assertion(
701            out,
702            assertion,
703            result_var,
704            class_name,
705            field_resolver,
706            effective_result_is_simple,
707            effective_result_is_bytes,
708            enum_fields,
709        );
710    }
711
712    let _ = writeln!(out, "    }}");
713}
714
715/// Build setup lines (e.g. handle creation) and the argument list for the function call.
716///
717/// Returns `(setup_lines, args_string)`.
718fn build_args_and_setup(
719    input: &serde_json::Value,
720    args: &[crate::config::ArgMapping],
721    class_name: &str,
722    options_type: Option<&str>,
723    fixture_id: &str,
724) -> (Vec<String>, String) {
725    if args.is_empty() {
726        return (Vec::new(), String::new());
727    }
728
729    let mut setup_lines: Vec<String> = Vec::new();
730    let mut parts: Vec<String> = Vec::new();
731
732    for arg in args {
733        if arg.arg_type == "mock_url" {
734            setup_lines.push(format!(
735                "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
736                arg.name,
737            ));
738            parts.push(arg.name.clone());
739            continue;
740        }
741
742        if arg.arg_type == "handle" {
743            // Generate a createEngine (or equivalent) call and pass the variable.
744            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
745            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
746            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
747            if config_value.is_null()
748                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
749            {
750                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
751            } else {
752                let json_str = serde_json::to_string(config_value).unwrap_or_default();
753                let name = &arg.name;
754                setup_lines.push(format!(
755                    "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
756                    escape_java(&json_str),
757                ));
758                setup_lines.push(format!(
759                    "var {} = {class_name}.{constructor_name}({name}Config);",
760                    arg.name,
761                    name = name,
762                ));
763            }
764            parts.push(arg.name.clone());
765            continue;
766        }
767
768        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
769        let val = input.get(field);
770        match val {
771            None | Some(serde_json::Value::Null) if arg.optional => {
772                // Optional arg with no fixture value: emit positional null/default so the call
773                // has the right arity. For json_object optional args, deserialise an empty object
774                // so we get the right type rather than a raw null.
775                if arg.arg_type == "json_object" {
776                    if let Some(opts_type) = options_type {
777                        parts.push(format!("MAPPER.readValue(\"{{}}\", {opts_type}.class)"));
778                    } else {
779                        parts.push("null".to_string());
780                    }
781                } else {
782                    parts.push("null".to_string());
783                }
784            }
785            None | Some(serde_json::Value::Null) => {
786                // Required arg with no fixture value: pass a language-appropriate default.
787                let default_val = match arg.arg_type.as_str() {
788                    "string" | "file_path" => "\"\"".to_string(),
789                    "int" | "integer" => "0".to_string(),
790                    "float" | "number" => "0.0d".to_string(),
791                    "bool" | "boolean" => "false".to_string(),
792                    _ => "null".to_string(),
793                };
794                parts.push(default_val);
795            }
796            Some(v) => {
797                if arg.arg_type == "json_object" {
798                    // Array json_object args: emit inline Java list expression.
799                    // Use element_type to emit the correct numeric literal suffix (f vs d).
800                    if v.is_array() {
801                        let elem_type = arg.element_type.as_deref();
802                        parts.push(json_to_java_typed(v, elem_type));
803                        continue;
804                    }
805                    // Object json_object args with options_type: use pre-deserialized variable.
806                    if options_type.is_some() {
807                        parts.push(arg.name.clone());
808                        continue;
809                    }
810                    parts.push(json_to_java(v));
811                    continue;
812                }
813                // bytes args must be passed as byte[], not String.
814                if arg.arg_type == "bytes" {
815                    let val = json_to_java(v);
816                    parts.push(format!("{val}.getBytes()"));
817                    continue;
818                }
819                // file_path args must be wrapped in java.nio.file.Path.of().
820                if arg.arg_type == "file_path" {
821                    let val = json_to_java(v);
822                    parts.push(format!("java.nio.file.Path.of({val})"));
823                    continue;
824                }
825                parts.push(json_to_java(v));
826            }
827        }
828    }
829
830    (setup_lines, parts.join(", "))
831}
832
833#[allow(clippy::too_many_arguments)]
834fn render_assertion(
835    out: &mut String,
836    assertion: &Assertion,
837    result_var: &str,
838    class_name: &str,
839    field_resolver: &FieldResolver,
840    result_is_simple: bool,
841    result_is_bytes: bool,
842    enum_fields: &HashSet<String>,
843) {
844    // Handle synthetic/virtual fields that are computed rather than direct record accessors.
845    if let Some(f) = &assertion.field {
846        match f.as_str() {
847            // ---- ExtractionResult chunk-level computed predicates ----
848            "chunks_have_content" => {
849                let pred = format!(
850                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.content() != null && !c.content().isBlank())"
851                );
852                match assertion.assertion_type.as_str() {
853                    "is_true" => {
854                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
855                    }
856                    "is_false" => {
857                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
858                    }
859                    _ => {
860                        let _ = writeln!(
861                            out,
862                            "        // skipped: unsupported assertion on synthetic field '{f}'"
863                        );
864                    }
865                }
866                return;
867            }
868            "chunks_have_heading_context" => {
869                let pred = format!(
870                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.metadata().headingContext().isPresent())"
871                );
872                match assertion.assertion_type.as_str() {
873                    "is_true" => {
874                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
875                    }
876                    "is_false" => {
877                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
878                    }
879                    _ => {
880                        let _ = writeln!(
881                            out,
882                            "        // skipped: unsupported assertion on synthetic field '{f}'"
883                        );
884                    }
885                }
886                return;
887            }
888            "chunks_have_embeddings" => {
889                let pred = format!(
890                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.embedding() != null && !c.embedding().isEmpty())"
891                );
892                match assertion.assertion_type.as_str() {
893                    "is_true" => {
894                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
895                    }
896                    "is_false" => {
897                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
898                    }
899                    _ => {
900                        let _ = writeln!(
901                            out,
902                            "        // skipped: unsupported assertion on synthetic field '{f}'"
903                        );
904                    }
905                }
906                return;
907            }
908            "first_chunk_starts_with_heading" => {
909                let pred = format!(
910                    "{result_var}.chunks().orElse(java.util.List.of()).stream().findFirst().map(c -> c.metadata().headingContext().isPresent()).orElse(false)"
911                );
912                match assertion.assertion_type.as_str() {
913                    "is_true" => {
914                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
915                    }
916                    "is_false" => {
917                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
918                    }
919                    _ => {
920                        let _ = writeln!(
921                            out,
922                            "        // skipped: unsupported assertion on synthetic field '{f}'"
923                        );
924                    }
925                }
926                return;
927            }
928            // ---- EmbedResponse virtual fields ----
929            // When result_is_simple=true the result IS List<List<Float>> (the raw embeddings list).
930            // When result_is_simple=false the result has an .embeddings() accessor.
931            "embedding_dimensions" => {
932                // Dimension = size of the first embedding vector in the list.
933                let embed_list = if result_is_simple {
934                    result_var.to_string()
935                } else {
936                    format!("{result_var}.embeddings()")
937                };
938                let expr = format!("({embed_list}.isEmpty() ? 0 : {embed_list}.get(0).size())");
939                match assertion.assertion_type.as_str() {
940                    "equals" => {
941                        if let Some(val) = &assertion.value {
942                            let java_val = json_to_java(val);
943                            let _ = writeln!(out, "        assertEquals({java_val}, {expr});");
944                        }
945                    }
946                    "greater_than" => {
947                        if let Some(val) = &assertion.value {
948                            let java_val = json_to_java(val);
949                            let _ = writeln!(
950                                out,
951                                "        assertTrue({expr} > {java_val}, \"expected > {java_val}\");"
952                            );
953                        }
954                    }
955                    _ => {
956                        let _ = writeln!(out, "        // skipped: unsupported assertion on '{f}'");
957                    }
958                }
959                return;
960            }
961            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
962                // These are validation predicates that require iterating the embedding matrix.
963                let embed_list = if result_is_simple {
964                    result_var.to_string()
965                } else {
966                    format!("{result_var}.embeddings()")
967                };
968                let pred = match f.as_str() {
969                    "embeddings_valid" => {
970                        format!("{embed_list}.stream().allMatch(e -> e != null && !e.isEmpty())")
971                    }
972                    "embeddings_finite" => {
973                        format!("{embed_list}.stream().flatMap(java.util.Collection::stream).allMatch(Float::isFinite)")
974                    }
975                    "embeddings_non_zero" => {
976                        format!("{embed_list}.stream().allMatch(e -> e.stream().anyMatch(v -> v != 0.0f))")
977                    }
978                    "embeddings_normalized" => format!(
979                        "{embed_list}.stream().allMatch(e -> {{ double n = e.stream().mapToDouble(v -> v * v).sum(); return Math.abs(n - 1.0) < 1e-3; }})"
980                    ),
981                    _ => unreachable!(),
982                };
983                match assertion.assertion_type.as_str() {
984                    "is_true" => {
985                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
986                    }
987                    "is_false" => {
988                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
989                    }
990                    _ => {
991                        let _ = writeln!(out, "        // skipped: unsupported assertion on '{f}'");
992                    }
993                }
994                return;
995            }
996            // ---- Fields not present on the Java ExtractionResult ----
997            "keywords" | "keywords_count" => {
998                let _ = writeln!(
999                    out,
1000                    "        // skipped: field '{f}' not available on Java ExtractionResult"
1001                );
1002                return;
1003            }
1004            // ---- metadata not_empty / is_empty: Metadata is a required record, not Optional ----
1005            // Metadata has no .isEmpty() method; check that at least one optional field is present.
1006            "metadata" => {
1007                match assertion.assertion_type.as_str() {
1008                    "not_empty" => {
1009                        let _ = writeln!(
1010                            out,
1011                            "        assertTrue({result_var}.metadata().title().isPresent() || {result_var}.metadata().subject().isPresent() || !{result_var}.metadata().additional().isEmpty(), \"expected non-empty value\");"
1012                        );
1013                        return;
1014                    }
1015                    "is_empty" => {
1016                        let _ = writeln!(
1017                            out,
1018                            "        assertFalse({result_var}.metadata().title().isPresent() || {result_var}.metadata().subject().isPresent() || !{result_var}.metadata().additional().isEmpty(), \"expected empty value\");"
1019                        );
1020                        return;
1021                    }
1022                    _ => {} // fall through to normal handling
1023                }
1024            }
1025            _ => {}
1026        }
1027    }
1028
1029    // Skip assertions on fields that don't exist on the result type.
1030    if let Some(f) = &assertion.field {
1031        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1032            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1033            return;
1034        }
1035    }
1036
1037    // Determine if this field is an enum type (no `.contains()` on enums in Java).
1038    // Check both the raw fixture field path and the resolved (aliased) path so that
1039    // `fields_enum` entries can use either form (e.g., `"assets[].category"` or the
1040    // resolved `"assets[].asset_category"`).
1041    let field_is_enum = assertion
1042        .field
1043        .as_deref()
1044        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1045
1046    let field_expr = if result_is_simple {
1047        result_var.to_string()
1048    } else {
1049        match &assertion.field {
1050            Some(f) if !f.is_empty() => {
1051                let accessor = field_resolver.accessor(f, "java", result_var);
1052                let resolved = field_resolver.resolve(f);
1053                // Unwrap Optional fields with a type-appropriate fallback.
1054                // Map.get() returns nullable, not Optional, so skip .orElse() for map access.
1055                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
1056                    // Choose the right orElse fallback based on the assertion type and field type.
1057                    match assertion.assertion_type.as_str() {
1058                        // For not_empty / is_empty on Optional fields, return the raw Optional
1059                        // so the assertion arms can call isPresent()/isEmpty().
1060                        "not_empty" | "is_empty" => accessor,
1061                        // For size/count assertions on Optional<List<T>> fields, use List.of() fallback.
1062                        "count_min" | "count_equals" => {
1063                            format!("{accessor}.orElse(java.util.List.of())")
1064                        }
1065                        // For numeric comparisons on Optional<Long/Integer> fields, use 0L.
1066                        "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
1067                            if field_resolver.is_array(resolved) {
1068                                format!("{accessor}.orElse(java.util.List.of())")
1069                            } else {
1070                                format!("{accessor}.orElse(0L)")
1071                            }
1072                        }
1073                        _ if field_resolver.is_array(resolved) => {
1074                            format!("{accessor}.orElse(java.util.List.of())")
1075                        }
1076                        _ => format!("{accessor}.orElse(\"\")"),
1077                    }
1078                } else {
1079                    accessor
1080                }
1081            }
1082            _ => result_var.to_string(),
1083        }
1084    };
1085
1086    // For enum fields, string-based assertions need .getValue() to convert the enum to
1087    // its serde-serialized lowercase string value (e.g., AssetCategory.Image -> "image").
1088    // All alef-generated Java enums expose a getValue() method annotated with @JsonValue.
1089    let string_expr = if field_is_enum {
1090        format!("{field_expr}.getValue()")
1091    } else {
1092        field_expr.clone()
1093    };
1094
1095    match assertion.assertion_type.as_str() {
1096        "equals" => {
1097            if let Some(expected) = &assertion.value {
1098                let java_val = json_to_java(expected);
1099                if expected.is_string() {
1100                    let _ = writeln!(out, "        assertEquals({java_val}, {string_expr}.trim());");
1101                } else {
1102                    let _ = writeln!(out, "        assertEquals({java_val}, {field_expr});");
1103                }
1104            }
1105        }
1106        "contains" => {
1107            if let Some(expected) = &assertion.value {
1108                let java_val = json_to_java(expected);
1109                let _ = writeln!(
1110                    out,
1111                    "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
1112                );
1113            }
1114        }
1115        "contains_all" => {
1116            if let Some(values) = &assertion.values {
1117                for val in values {
1118                    let java_val = json_to_java(val);
1119                    let _ = writeln!(
1120                        out,
1121                        "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
1122                    );
1123                }
1124            }
1125        }
1126        "not_contains" => {
1127            if let Some(expected) = &assertion.value {
1128                let java_val = json_to_java(expected);
1129                let _ = writeln!(
1130                    out,
1131                    "        assertFalse({string_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
1132                );
1133            }
1134        }
1135        "not_empty" => {
1136            let _ = writeln!(
1137                out,
1138                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
1139            );
1140        }
1141        "is_empty" => {
1142            let _ = writeln!(
1143                out,
1144                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
1145            );
1146        }
1147        "contains_any" => {
1148            if let Some(values) = &assertion.values {
1149                let checks: Vec<String> = values
1150                    .iter()
1151                    .map(|v| {
1152                        let java_val = json_to_java(v);
1153                        format!("{string_expr}.contains({java_val})")
1154                    })
1155                    .collect();
1156                let joined = checks.join(" || ");
1157                let _ = writeln!(
1158                    out,
1159                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\");"
1160                );
1161            }
1162        }
1163        "greater_than" => {
1164            if let Some(val) = &assertion.value {
1165                let java_val = json_to_java(val);
1166                let _ = writeln!(
1167                    out,
1168                    "        assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
1169                );
1170            }
1171        }
1172        "less_than" => {
1173            if let Some(val) = &assertion.value {
1174                let java_val = json_to_java(val);
1175                let _ = writeln!(
1176                    out,
1177                    "        assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
1178                );
1179            }
1180        }
1181        "greater_than_or_equal" => {
1182            if let Some(val) = &assertion.value {
1183                let java_val = json_to_java(val);
1184                let _ = writeln!(
1185                    out,
1186                    "        assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
1187                );
1188            }
1189        }
1190        "less_than_or_equal" => {
1191            if let Some(val) = &assertion.value {
1192                let java_val = json_to_java(val);
1193                let _ = writeln!(
1194                    out,
1195                    "        assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
1196                );
1197            }
1198        }
1199        "starts_with" => {
1200            if let Some(expected) = &assertion.value {
1201                let java_val = json_to_java(expected);
1202                let _ = writeln!(
1203                    out,
1204                    "        assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
1205                );
1206            }
1207        }
1208        "ends_with" => {
1209            if let Some(expected) = &assertion.value {
1210                let java_val = json_to_java(expected);
1211                let _ = writeln!(
1212                    out,
1213                    "        assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
1214                );
1215            }
1216        }
1217        "min_length" => {
1218            if let Some(val) = &assertion.value {
1219                if let Some(n) = val.as_u64() {
1220                    // byte[] uses `.length` (array field), String uses `.length()` (method).
1221                    let len_expr = if result_is_bytes {
1222                        format!("{field_expr}.length")
1223                    } else {
1224                        format!("{field_expr}.length()")
1225                    };
1226                    let _ = writeln!(
1227                        out,
1228                        "        assertTrue({len_expr} >= {n}, \"expected length >= {n}\");"
1229                    );
1230                }
1231            }
1232        }
1233        "max_length" => {
1234            if let Some(val) = &assertion.value {
1235                if let Some(n) = val.as_u64() {
1236                    let len_expr = if result_is_bytes {
1237                        format!("{field_expr}.length")
1238                    } else {
1239                        format!("{field_expr}.length()")
1240                    };
1241                    let _ = writeln!(
1242                        out,
1243                        "        assertTrue({len_expr} <= {n}, \"expected length <= {n}\");"
1244                    );
1245                }
1246            }
1247        }
1248        "count_min" => {
1249            if let Some(val) = &assertion.value {
1250                if let Some(n) = val.as_u64() {
1251                    let _ = writeln!(
1252                        out,
1253                        "        assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
1254                    );
1255                }
1256            }
1257        }
1258        "count_equals" => {
1259            if let Some(val) = &assertion.value {
1260                if let Some(n) = val.as_u64() {
1261                    let _ = writeln!(
1262                        out,
1263                        "        assertEquals({n}, {field_expr}.size(), \"expected exactly {n} elements\");"
1264                    );
1265                }
1266            }
1267        }
1268        "is_true" => {
1269            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\");");
1270        }
1271        "is_false" => {
1272            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\");");
1273        }
1274        "method_result" => {
1275            if let Some(method_name) = &assertion.method {
1276                let call_expr = build_java_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1277                let check = assertion.check.as_deref().unwrap_or("is_true");
1278                // Methods that return a collection (List) rather than a scalar.
1279                let method_returns_collection =
1280                    matches!(method_name.as_str(), "find_nodes_by_type" | "findNodesByType");
1281                match check {
1282                    "equals" => {
1283                        if let Some(val) = &assertion.value {
1284                            if val.is_boolean() {
1285                                if val.as_bool() == Some(true) {
1286                                    let _ = writeln!(out, "        assertTrue({call_expr});");
1287                                } else {
1288                                    let _ = writeln!(out, "        assertFalse({call_expr});");
1289                                }
1290                            } else if method_returns_collection {
1291                                let java_val = json_to_java(val);
1292                                let _ = writeln!(out, "        assertEquals({java_val}, {call_expr}.size());");
1293                            } else {
1294                                let java_val = json_to_java(val);
1295                                let _ = writeln!(out, "        assertEquals({java_val}, {call_expr});");
1296                            }
1297                        }
1298                    }
1299                    "is_true" => {
1300                        let _ = writeln!(out, "        assertTrue({call_expr});");
1301                    }
1302                    "is_false" => {
1303                        let _ = writeln!(out, "        assertFalse({call_expr});");
1304                    }
1305                    "greater_than_or_equal" => {
1306                        if let Some(val) = &assertion.value {
1307                            let n = val.as_u64().unwrap_or(0);
1308                            let _ = writeln!(out, "        assertTrue({call_expr} >= {n}, \"expected >= {n}\");");
1309                        }
1310                    }
1311                    "count_min" => {
1312                        if let Some(val) = &assertion.value {
1313                            let n = val.as_u64().unwrap_or(0);
1314                            let _ = writeln!(
1315                                out,
1316                                "        assertTrue({call_expr}.size() >= {n}, \"expected at least {n} elements\");"
1317                            );
1318                        }
1319                    }
1320                    "is_error" => {
1321                        let _ = writeln!(out, "        assertThrows(Exception.class, () -> {{ {call_expr}; }});");
1322                    }
1323                    "contains" => {
1324                        if let Some(val) = &assertion.value {
1325                            let java_val = json_to_java(val);
1326                            let _ = writeln!(
1327                                out,
1328                                "        assertTrue({call_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
1329                            );
1330                        }
1331                    }
1332                    other_check => {
1333                        panic!("Java e2e generator: unsupported method_result check type: {other_check}");
1334                    }
1335                }
1336            } else {
1337                panic!("Java e2e generator: method_result assertion missing 'method' field");
1338            }
1339        }
1340        "matches_regex" => {
1341            if let Some(expected) = &assertion.value {
1342                let java_val = json_to_java(expected);
1343                let _ = writeln!(
1344                    out,
1345                    "        assertTrue({string_expr}.matches({java_val}), \"expected value to match regex: \" + {java_val});"
1346                );
1347            }
1348        }
1349        "not_error" => {
1350            // Already handled by the call succeeding without exception.
1351        }
1352        "error" => {
1353            // Handled at the test method level.
1354        }
1355        other => {
1356            panic!("Java e2e generator: unsupported assertion type: {other}");
1357        }
1358    }
1359}
1360
1361/// Build a Java call expression for a `method_result` assertion on a tree-sitter Tree.
1362///
1363/// Maps method names to the appropriate Java static/instance method calls.
1364fn build_java_method_call(
1365    result_var: &str,
1366    method_name: &str,
1367    args: Option<&serde_json::Value>,
1368    class_name: &str,
1369) -> String {
1370    match method_name {
1371        "root_child_count" => format!("{result_var}.rootNode().childCount()"),
1372        "root_node_type" => format!("{result_var}.rootNode().kind()"),
1373        "named_children_count" => format!("{result_var}.rootNode().namedChildCount()"),
1374        "has_error_nodes" => format!("{class_name}.treeHasErrorNodes({result_var})"),
1375        "error_count" | "tree_error_count" => format!("{class_name}.treeErrorCount({result_var})"),
1376        "tree_to_sexp" => format!("{class_name}.treeToSexp({result_var})"),
1377        "contains_node_type" => {
1378            let node_type = args
1379                .and_then(|a| a.get("node_type"))
1380                .and_then(|v| v.as_str())
1381                .unwrap_or("");
1382            format!("{class_name}.treeContainsNodeType({result_var}, \"{node_type}\")")
1383        }
1384        "find_nodes_by_type" => {
1385            let node_type = args
1386                .and_then(|a| a.get("node_type"))
1387                .and_then(|v| v.as_str())
1388                .unwrap_or("");
1389            format!("{class_name}.findNodesByType({result_var}, \"{node_type}\")")
1390        }
1391        "run_query" => {
1392            let query_source = args
1393                .and_then(|a| a.get("query_source"))
1394                .and_then(|v| v.as_str())
1395                .unwrap_or("");
1396            let language = args
1397                .and_then(|a| a.get("language"))
1398                .and_then(|v| v.as_str())
1399                .unwrap_or("");
1400            let escaped_query = escape_java(query_source);
1401            format!("{class_name}.runQuery({result_var}, \"{language}\", \"{escaped_query}\", source)")
1402        }
1403        _ => {
1404            format!("{result_var}.{}()", method_name.to_lower_camel_case())
1405        }
1406    }
1407}
1408
1409/// Convert a `serde_json::Value` to a Java literal string.
1410fn json_to_java(value: &serde_json::Value) -> String {
1411    json_to_java_typed(value, None)
1412}
1413
1414/// Convert a JSON value to a Java literal, optionally overriding number type for array elements.
1415/// `element_type` controls how numeric array elements are emitted: "f32" → `1.0f`, otherwise `1.0d`.
1416fn json_to_java_typed(value: &serde_json::Value, element_type: Option<&str>) -> String {
1417    match value {
1418        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
1419        serde_json::Value::Bool(b) => b.to_string(),
1420        serde_json::Value::Number(n) => {
1421            if n.is_f64() {
1422                match element_type {
1423                    Some("f32" | "float" | "Float") => format!("{}f", n),
1424                    _ => format!("{}d", n),
1425                }
1426            } else {
1427                n.to_string()
1428            }
1429        }
1430        serde_json::Value::Null => "null".to_string(),
1431        serde_json::Value::Array(arr) => {
1432            let items: Vec<String> = arr.iter().map(|v| json_to_java_typed(v, element_type)).collect();
1433            format!("java.util.List.of({})", items.join(", "))
1434        }
1435        serde_json::Value::Object(_) => {
1436            let json_str = serde_json::to_string(value).unwrap_or_default();
1437            format!("\"{}\"", escape_java(&json_str))
1438        }
1439    }
1440}
1441
1442// ---------------------------------------------------------------------------
1443// Visitor generation
1444// ---------------------------------------------------------------------------
1445
1446/// Build a Java visitor class and add setup lines. Returns the visitor variable name.
1447fn build_java_visitor(
1448    setup_lines: &mut Vec<String>,
1449    visitor_spec: &crate::fixture::VisitorSpec,
1450    class_name: &str,
1451) -> String {
1452    setup_lines.push("class _TestVisitor implements TestVisitor {".to_string());
1453    for (method_name, action) in &visitor_spec.callbacks {
1454        emit_java_visitor_method(setup_lines, method_name, action, class_name);
1455    }
1456    setup_lines.push("}".to_string());
1457    setup_lines.push("var visitor = new _TestVisitor();".to_string());
1458    "visitor".to_string()
1459}
1460
1461/// Emit a Java visitor method for a callback action.
1462fn emit_java_visitor_method(
1463    setup_lines: &mut Vec<String>,
1464    method_name: &str,
1465    action: &CallbackAction,
1466    _class_name: &str,
1467) {
1468    let camel_method = method_to_camel(method_name);
1469    let params = match method_name {
1470        "visit_link" => "VisitContext ctx, String href, String text, String title",
1471        "visit_image" => "VisitContext ctx, String src, String alt, String title",
1472        "visit_heading" => "VisitContext ctx, int level, String text, String id",
1473        "visit_code_block" => "VisitContext ctx, String lang, String code",
1474        "visit_code_inline"
1475        | "visit_strong"
1476        | "visit_emphasis"
1477        | "visit_strikethrough"
1478        | "visit_underline"
1479        | "visit_subscript"
1480        | "visit_superscript"
1481        | "visit_mark"
1482        | "visit_button"
1483        | "visit_summary"
1484        | "visit_figcaption"
1485        | "visit_definition_term"
1486        | "visit_definition_description" => "VisitContext ctx, String text",
1487        "visit_text" => "VisitContext ctx, String text",
1488        "visit_list_item" => "VisitContext ctx, boolean ordered, String marker, String text",
1489        "visit_blockquote" => "VisitContext ctx, String content, int depth",
1490        "visit_table_row" => "VisitContext ctx, java.util.List<String> cells, boolean isHeader",
1491        "visit_custom_element" => "VisitContext ctx, String tagName, String html",
1492        "visit_form" => "VisitContext ctx, String actionUrl, String method",
1493        "visit_input" => "VisitContext ctx, String inputType, String name, String value",
1494        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, String src",
1495        "visit_details" => "VisitContext ctx, boolean isOpen",
1496        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1497            "VisitContext ctx, String output"
1498        }
1499        "visit_list_start" => "VisitContext ctx, boolean ordered",
1500        "visit_list_end" => "VisitContext ctx, boolean ordered, String output",
1501        _ => "VisitContext ctx",
1502    };
1503
1504    setup_lines.push(format!("    @Override public VisitResult {camel_method}({params}) {{"));
1505    match action {
1506        CallbackAction::Skip => {
1507            setup_lines.push("        return VisitResult.skip();".to_string());
1508        }
1509        CallbackAction::Continue => {
1510            setup_lines.push("        return VisitResult.continue_();".to_string());
1511        }
1512        CallbackAction::PreserveHtml => {
1513            setup_lines.push("        return VisitResult.preserveHtml();".to_string());
1514        }
1515        CallbackAction::Custom { output } => {
1516            let escaped = escape_java(output);
1517            setup_lines.push(format!("        return VisitResult.custom(\"{escaped}\");"));
1518        }
1519        CallbackAction::CustomTemplate { template } => {
1520            let escaped = escape_java(template);
1521            setup_lines.push(format!(
1522                "        return VisitResult.custom(String.format(\"{escaped}\"));"
1523            ));
1524        }
1525    }
1526    setup_lines.push("    }".to_string());
1527}
1528
1529/// Convert snake_case method names to Java camelCase.
1530fn method_to_camel(snake: &str) -> String {
1531    snake.to_lower_camel_case()
1532}