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