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