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};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use heck::ToUpperCamelCase;
14use std::collections::HashSet;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20/// Java e2e code generator.
21pub struct JavaCodegen;
22
23impl E2eCodegen for JavaCodegen {
24    fn generate(
25        &self,
26        groups: &[FixtureGroup],
27        e2e_config: &E2eConfig,
28        alef_config: &AlefConfig,
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32
33        let mut files = Vec::new();
34
35        // Resolve call config with overrides.
36        let call = &e2e_config.call;
37        let overrides = call.overrides.get(lang);
38        let _module_path = overrides
39            .and_then(|o| o.module.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.module.clone());
42        let function_name = overrides
43            .and_then(|o| o.function.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.function.clone());
46        let class_name = overrides
47            .and_then(|o| o.class.as_ref())
48            .cloned()
49            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
50        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
51        let result_var = &call.result_var;
52
53        // Resolve package config.
54        let java_pkg = e2e_config.resolve_package("java");
55        let pkg_name = java_pkg
56            .as_ref()
57            .and_then(|p| p.name.as_ref())
58            .cloned()
59            .unwrap_or_else(|| alef_config.crate_config.name.clone());
60
61        // Resolve Java package info for the dependency.
62        let java_group_id = alef_config.java_group_id();
63        let pkg_version = alef_config.resolved_version().unwrap_or_else(|| "0.1.0".to_string());
64
65        // Generate pom.xml.
66        files.push(GeneratedFile {
67            path: output_base.join("pom.xml"),
68            content: render_pom_xml(&pkg_name, &java_group_id, &pkg_version, e2e_config.dep_mode),
69            generated_header: false,
70        });
71
72        // Generate test files per category.
73        let test_base = output_base
74            .join("src")
75            .join("test")
76            .join("java")
77            .join("dev")
78            .join("kreuzberg")
79            .join("e2e");
80
81        // Resolve options_type from override.
82        let options_type = overrides.and_then(|o| o.options_type.clone());
83        let field_resolver = FieldResolver::new(
84            &e2e_config.fields,
85            &e2e_config.fields_optional,
86            &e2e_config.result_fields,
87            &e2e_config.fields_array,
88        );
89
90        for group in groups {
91            let active: Vec<&Fixture> = group
92                .fixtures
93                .iter()
94                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
95                .collect();
96
97            if active.is_empty() {
98                continue;
99            }
100
101            let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
102            let content = render_test_file(
103                &group.category,
104                &active,
105                &class_name,
106                &function_name,
107                result_var,
108                &e2e_config.call.args,
109                options_type.as_deref(),
110                &field_resolver,
111                result_is_simple,
112                &e2e_config.fields_enum,
113            );
114            files.push(GeneratedFile {
115                path: test_base.join(class_file_name),
116                content,
117                generated_header: true,
118            });
119        }
120
121        Ok(files)
122    }
123
124    fn language_name(&self) -> &'static str {
125        "java"
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Rendering
131// ---------------------------------------------------------------------------
132
133fn render_pom_xml(
134    pkg_name: &str,
135    java_group_id: &str,
136    pkg_version: &str,
137    dep_mode: crate::config::DependencyMode,
138) -> String {
139    // pkg_name may be in "groupId:artifactId" Maven format; split accordingly.
140    let (dep_group_id, dep_artifact_id) = if let Some((g, a)) = pkg_name.split_once(':') {
141        (g, a)
142    } else {
143        (java_group_id, pkg_name)
144    };
145    let artifact_id = format!("{dep_artifact_id}-e2e-java");
146    let dep_block = match dep_mode {
147        crate::config::DependencyMode::Registry => {
148            format!(
149                r#"        <dependency>
150            <groupId>{dep_group_id}</groupId>
151            <artifactId>{dep_artifact_id}</artifactId>
152            <version>{pkg_version}</version>
153        </dependency>"#
154            )
155        }
156        crate::config::DependencyMode::Local => {
157            format!(
158                r#"        <dependency>
159            <groupId>{dep_group_id}</groupId>
160            <artifactId>{dep_artifact_id}</artifactId>
161            <version>{pkg_version}</version>
162            <scope>system</scope>
163            <systemPath>${{project.basedir}}/../../packages/java/target/{dep_artifact_id}-{pkg_version}.jar</systemPath>
164        </dependency>"#
165            )
166        }
167    };
168    format!(
169        r#"<?xml version="1.0" encoding="UTF-8"?>
170<project xmlns="http://maven.apache.org/POM/4.0.0"
171         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
172         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
173    <modelVersion>4.0.0</modelVersion>
174
175    <groupId>dev.kreuzberg</groupId>
176    <artifactId>{artifact_id}</artifactId>
177    <version>0.1.0</version>
178
179    <properties>
180        <maven.compiler.source>25</maven.compiler.source>
181        <maven.compiler.target>25</maven.compiler.target>
182        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
183        <junit.version>5.11.4</junit.version>
184    </properties>
185
186    <dependencies>
187{dep_block}
188        <dependency>
189            <groupId>com.fasterxml.jackson.core</groupId>
190            <artifactId>jackson-databind</artifactId>
191            <version>2.18.2</version>
192        </dependency>
193        <dependency>
194            <groupId>com.fasterxml.jackson.datatype</groupId>
195            <artifactId>jackson-datatype-jdk8</artifactId>
196            <version>2.18.2</version>
197        </dependency>
198        <dependency>
199            <groupId>org.junit.jupiter</groupId>
200            <artifactId>junit-jupiter</artifactId>
201            <version>${{junit.version}}</version>
202            <scope>test</scope>
203        </dependency>
204    </dependencies>
205
206    <build>
207        <plugins>
208            <plugin>
209                <groupId>org.codehaus.mojo</groupId>
210                <artifactId>build-helper-maven-plugin</artifactId>
211                <version>3.6.0</version>
212                <executions>
213                    <execution>
214                        <id>add-test-source</id>
215                        <phase>generate-test-sources</phase>
216                        <goals>
217                            <goal>add-test-source</goal>
218                        </goals>
219                        <configuration>
220                            <sources>
221                                <source>src/test/java</source>
222                            </sources>
223                        </configuration>
224                    </execution>
225                </executions>
226            </plugin>
227            <plugin>
228                <groupId>org.apache.maven.plugins</groupId>
229                <artifactId>maven-surefire-plugin</artifactId>
230                <version>3.5.2</version>
231                <configuration>
232                    <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=../../target/release</argLine>
233                </configuration>
234            </plugin>
235        </plugins>
236    </build>
237</project>
238"#
239    )
240}
241
242#[allow(clippy::too_many_arguments)]
243fn render_test_file(
244    category: &str,
245    fixtures: &[&Fixture],
246    class_name: &str,
247    function_name: &str,
248    result_var: &str,
249    args: &[crate::config::ArgMapping],
250    options_type: Option<&str>,
251    field_resolver: &FieldResolver,
252    result_is_simple: bool,
253    enum_fields: &HashSet<String>,
254) -> String {
255    let mut out = String::new();
256    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
257    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
258
259    // If the class_name is fully qualified (contains '.'), import it and use
260    // only the simple name for method calls.  Otherwise use it as-is.
261    let (import_path, simple_class) = if class_name.contains('.') {
262        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
263        (class_name, simple)
264    } else {
265        ("", class_name)
266    };
267
268    let _ = writeln!(out, "package dev.kreuzberg.e2e;");
269    let _ = writeln!(out);
270
271    // Check if any fixture uses a json_object arg with options_type (needs ObjectMapper).
272    let needs_object_mapper_for_options = options_type.is_some()
273        && fixtures.iter().any(|f| {
274            args.iter().any(|arg| {
275                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
276                arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
277            })
278        });
279    // Also need ObjectMapper when a handle arg has a non-null config.
280    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
281        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
282            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
283            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
284        })
285    });
286    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
287
288    let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
289    let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
290    if !import_path.is_empty() {
291        let _ = writeln!(out, "import {import_path};");
292    }
293    if needs_object_mapper {
294        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
295        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
296    }
297    // Import the options type if tests use it (it's in the same package as the main class).
298    if let Some(opts_type) = options_type {
299        if needs_object_mapper {
300            // Derive the fully-qualified name from the main class import path.
301            let opts_package = if !import_path.is_empty() {
302                let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
303                format!("{pkg}.{opts_type}")
304            } else {
305                opts_type.to_string()
306            };
307            let _ = writeln!(out, "import {opts_package};");
308        }
309    }
310    // Import CrawlConfig when handle args need JSON deserialization.
311    if needs_object_mapper_for_handle && !import_path.is_empty() {
312        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
313        let _ = writeln!(out, "import {pkg}.CrawlConfig;");
314    }
315    let _ = writeln!(out);
316
317    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
318    let _ = writeln!(out, "class {test_class_name} {{");
319
320    if needs_object_mapper {
321        let _ = writeln!(out);
322        let _ = writeln!(
323            out,
324            "    private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
325        );
326    }
327
328    for fixture in fixtures {
329        render_test_method(
330            &mut out,
331            fixture,
332            simple_class,
333            function_name,
334            result_var,
335            args,
336            options_type,
337            field_resolver,
338            result_is_simple,
339            enum_fields,
340        );
341        let _ = writeln!(out);
342    }
343
344    let _ = writeln!(out, "}}");
345    out
346}
347
348#[allow(clippy::too_many_arguments)]
349fn render_test_method(
350    out: &mut String,
351    fixture: &Fixture,
352    class_name: &str,
353    function_name: &str,
354    result_var: &str,
355    args: &[crate::config::ArgMapping],
356    options_type: Option<&str>,
357    field_resolver: &FieldResolver,
358    result_is_simple: bool,
359    enum_fields: &HashSet<String>,
360) {
361    let method_name = fixture.id.to_upper_camel_case();
362    let description = &fixture.description;
363    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
364
365    // Check if this test needs ObjectMapper deserialization for json_object args.
366    let needs_deser = options_type.is_some()
367        && args
368            .iter()
369            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
370
371    // Always add throws Exception since the convert method may throw checked exceptions.
372    let throws_clause = " throws Exception";
373
374    let _ = writeln!(out, "    @Test");
375    let _ = writeln!(out, "    void test{method_name}(){throws_clause} {{");
376    let _ = writeln!(out, "        // {description}");
377
378    // Emit ObjectMapper deserialization bindings for json_object args.
379    if let (true, Some(opts_type)) = (needs_deser, options_type) {
380        for arg in args {
381            if arg.arg_type == "json_object" {
382                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
383                if let Some(val) = fixture.input.get(field) {
384                    if !val.is_null() {
385                        // Fixture keys are camelCase; the Java ConversionOptions record uses
386                        // @JsonProperty("snake_case") annotations. Normalize keys so Jackson
387                        // can deserialize them correctly.
388                        let normalized = super::normalize_json_keys_to_snake_case(val);
389                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
390                        let var_name = &arg.name;
391                        let _ = writeln!(
392                            out,
393                            "        var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
394                            escape_java(&json_str)
395                        );
396                    }
397                }
398            }
399        }
400    }
401
402    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
403
404    // Build visitor if present and add to setup
405    let mut visitor_arg = String::new();
406    if let Some(visitor_spec) = &fixture.visitor {
407        visitor_arg = build_java_visitor(&mut setup_lines, visitor_spec, class_name);
408    }
409
410    for line in &setup_lines {
411        let _ = writeln!(out, "        {line}");
412    }
413
414    let final_args = if visitor_arg.is_empty() {
415        args_str
416    } else {
417        format!("{args_str}, {visitor_arg}")
418    };
419
420    if expects_error {
421        let _ = writeln!(
422            out,
423            "        assertThrows(Exception.class, () -> {class_name}.{function_name}({final_args}));"
424        );
425        let _ = writeln!(out, "    }}");
426        return;
427    }
428
429    let _ = writeln!(
430        out,
431        "        var {result_var} = {class_name}.{function_name}({final_args});"
432    );
433
434    for assertion in &fixture.assertions {
435        render_assertion(
436            out,
437            assertion,
438            result_var,
439            field_resolver,
440            result_is_simple,
441            enum_fields,
442        );
443    }
444
445    let _ = writeln!(out, "    }}");
446}
447
448/// Build setup lines (e.g. handle creation) and the argument list for the function call.
449///
450/// Returns `(setup_lines, args_string)`.
451fn build_args_and_setup(
452    input: &serde_json::Value,
453    args: &[crate::config::ArgMapping],
454    class_name: &str,
455    options_type: Option<&str>,
456    fixture_id: &str,
457) -> (Vec<String>, String) {
458    if args.is_empty() {
459        return (Vec::new(), json_to_java(input));
460    }
461
462    let mut setup_lines: Vec<String> = Vec::new();
463    let mut parts: Vec<String> = Vec::new();
464
465    for arg in args {
466        if arg.arg_type == "mock_url" {
467            setup_lines.push(format!(
468                "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
469                arg.name,
470            ));
471            parts.push(arg.name.clone());
472            continue;
473        }
474
475        if arg.arg_type == "handle" {
476            // Generate a createEngine (or equivalent) call and pass the variable.
477            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
478            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
479            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
480            if config_value.is_null()
481                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
482            {
483                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
484            } else {
485                let json_str = serde_json::to_string(config_value).unwrap_or_default();
486                let name = &arg.name;
487                setup_lines.push(format!(
488                    "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
489                    escape_java(&json_str),
490                ));
491                setup_lines.push(format!(
492                    "var {} = {class_name}.{constructor_name}({name}Config);",
493                    arg.name,
494                    name = name,
495                ));
496            }
497            parts.push(arg.name.clone());
498            continue;
499        }
500
501        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
502        let val = input.get(field);
503        match val {
504            None | Some(serde_json::Value::Null) if arg.optional => {
505                // Optional arg with no fixture value: skip entirely.
506                continue;
507            }
508            None | Some(serde_json::Value::Null) => {
509                // Required arg with no fixture value: pass a language-appropriate default.
510                let default_val = match arg.arg_type.as_str() {
511                    "string" => "\"\"".to_string(),
512                    "int" | "integer" => "0".to_string(),
513                    "float" | "number" => "0.0d".to_string(),
514                    "bool" | "boolean" => "false".to_string(),
515                    _ => "null".to_string(),
516                };
517                parts.push(default_val);
518            }
519            Some(v) => {
520                // For json_object args with options_type, use the pre-deserialized variable.
521                if arg.arg_type == "json_object" && options_type.is_some() {
522                    parts.push(arg.name.clone());
523                    continue;
524                }
525                parts.push(json_to_java(v));
526            }
527        }
528    }
529
530    (setup_lines, parts.join(", "))
531}
532
533fn render_assertion(
534    out: &mut String,
535    assertion: &Assertion,
536    result_var: &str,
537    field_resolver: &FieldResolver,
538    result_is_simple: bool,
539    enum_fields: &HashSet<String>,
540) {
541    // Skip assertions on fields that don't exist on the result type.
542    if let Some(f) = &assertion.field {
543        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
544            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
545            return;
546        }
547    }
548
549    // Determine if this field is an enum type (no `.contains()` on enums in Java).
550    // Check both the raw fixture field path and the resolved (aliased) path so that
551    // `fields_enum` entries can use either form (e.g., `"assets[].category"` or the
552    // resolved `"assets[].asset_category"`).
553    let field_is_enum = assertion
554        .field
555        .as_deref()
556        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
557
558    let field_expr = if result_is_simple {
559        result_var.to_string()
560    } else {
561        match &assertion.field {
562            Some(f) if !f.is_empty() => {
563                let accessor = field_resolver.accessor(f, "java", result_var);
564                let resolved = field_resolver.resolve(f);
565                // Unwrap Optional fields with .orElse("") for string comparisons.
566                // Map.get() returns nullable, not Optional, so skip .orElse() for map access.
567                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
568                    format!("{accessor}.orElse(\"\")")
569                } else {
570                    accessor
571                }
572            }
573            _ => result_var.to_string(),
574        }
575    };
576
577    // For enum fields, string-based assertions need .getValue() to convert the enum to
578    // its serde-serialized lowercase string value (e.g., AssetCategory.Image -> "image").
579    // All alef-generated Java enums expose a getValue() method annotated with @JsonValue.
580    let string_expr = if field_is_enum {
581        format!("{field_expr}.getValue()")
582    } else {
583        field_expr.clone()
584    };
585
586    match assertion.assertion_type.as_str() {
587        "equals" => {
588            if let Some(expected) = &assertion.value {
589                let java_val = json_to_java(expected);
590                if expected.is_string() {
591                    let _ = writeln!(out, "        assertEquals({java_val}, {string_expr}.trim());");
592                } else {
593                    let _ = writeln!(out, "        assertEquals({java_val}, {field_expr});");
594                }
595            }
596        }
597        "contains" => {
598            if let Some(expected) = &assertion.value {
599                let java_val = json_to_java(expected);
600                let _ = writeln!(
601                    out,
602                    "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
603                );
604            }
605        }
606        "contains_all" => {
607            if let Some(values) = &assertion.values {
608                for val in values {
609                    let java_val = json_to_java(val);
610                    let _ = writeln!(
611                        out,
612                        "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
613                    );
614                }
615            }
616        }
617        "not_contains" => {
618            if let Some(expected) = &assertion.value {
619                let java_val = json_to_java(expected);
620                let _ = writeln!(
621                    out,
622                    "        assertFalse({string_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
623                );
624            }
625        }
626        "not_empty" => {
627            let _ = writeln!(
628                out,
629                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
630            );
631        }
632        "is_empty" => {
633            let _ = writeln!(
634                out,
635                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
636            );
637        }
638        "contains_any" => {
639            if let Some(values) = &assertion.values {
640                let checks: Vec<String> = values
641                    .iter()
642                    .map(|v| {
643                        let java_val = json_to_java(v);
644                        format!("{string_expr}.contains({java_val})")
645                    })
646                    .collect();
647                let joined = checks.join(" || ");
648                let _ = writeln!(
649                    out,
650                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\");"
651                );
652            }
653        }
654        "greater_than" => {
655            if let Some(val) = &assertion.value {
656                let java_val = json_to_java(val);
657                let _ = writeln!(
658                    out,
659                    "        assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
660                );
661            }
662        }
663        "less_than" => {
664            if let Some(val) = &assertion.value {
665                let java_val = json_to_java(val);
666                let _ = writeln!(
667                    out,
668                    "        assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
669                );
670            }
671        }
672        "greater_than_or_equal" => {
673            if let Some(val) = &assertion.value {
674                let java_val = json_to_java(val);
675                let _ = writeln!(
676                    out,
677                    "        assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
678                );
679            }
680        }
681        "less_than_or_equal" => {
682            if let Some(val) = &assertion.value {
683                let java_val = json_to_java(val);
684                let _ = writeln!(
685                    out,
686                    "        assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
687                );
688            }
689        }
690        "starts_with" => {
691            if let Some(expected) = &assertion.value {
692                let java_val = json_to_java(expected);
693                let _ = writeln!(
694                    out,
695                    "        assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
696                );
697            }
698        }
699        "ends_with" => {
700            if let Some(expected) = &assertion.value {
701                let java_val = json_to_java(expected);
702                let _ = writeln!(
703                    out,
704                    "        assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
705                );
706            }
707        }
708        "min_length" => {
709            if let Some(val) = &assertion.value {
710                if let Some(n) = val.as_u64() {
711                    let _ = writeln!(
712                        out,
713                        "        assertTrue({field_expr}.length() >= {n}, \"expected length >= {n}\");"
714                    );
715                }
716            }
717        }
718        "max_length" => {
719            if let Some(val) = &assertion.value {
720                if let Some(n) = val.as_u64() {
721                    let _ = writeln!(
722                        out,
723                        "        assertTrue({field_expr}.length() <= {n}, \"expected length <= {n}\");"
724                    );
725                }
726            }
727        }
728        "count_min" => {
729            if let Some(val) = &assertion.value {
730                if let Some(n) = val.as_u64() {
731                    let _ = writeln!(
732                        out,
733                        "        assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
734                    );
735                }
736            }
737        }
738        "count_equals" => {
739            if let Some(val) = &assertion.value {
740                if let Some(n) = val.as_u64() {
741                    let _ = writeln!(
742                        out,
743                        "        assertEquals({n}, {field_expr}.size(), \"expected exactly {n} elements\");"
744                    );
745                }
746            }
747        }
748        "is_true" => {
749            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\");");
750        }
751        "not_error" => {
752            // Already handled by the call succeeding without exception.
753        }
754        "error" => {
755            // Handled at the test method level.
756        }
757        other => {
758            let _ = writeln!(out, "        // TODO: unsupported assertion type: {other}");
759        }
760    }
761}
762
763/// Convert a `serde_json::Value` to a Java literal string.
764fn json_to_java(value: &serde_json::Value) -> String {
765    match value {
766        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
767        serde_json::Value::Bool(b) => b.to_string(),
768        serde_json::Value::Number(n) => {
769            if n.is_f64() {
770                format!("{}d", n)
771            } else {
772                n.to_string()
773            }
774        }
775        serde_json::Value::Null => "null".to_string(),
776        serde_json::Value::Array(arr) => {
777            let items: Vec<String> = arr.iter().map(json_to_java).collect();
778            format!("java.util.List.of({})", items.join(", "))
779        }
780        serde_json::Value::Object(_) => {
781            let json_str = serde_json::to_string(value).unwrap_or_default();
782            format!("\"{}\"", escape_java(&json_str))
783        }
784    }
785}
786
787// ---------------------------------------------------------------------------
788// Visitor generation
789// ---------------------------------------------------------------------------
790
791/// Build a Java visitor class and add setup lines. Returns the visitor variable name.
792fn build_java_visitor(
793    setup_lines: &mut Vec<String>,
794    visitor_spec: &crate::fixture::VisitorSpec,
795    class_name: &str,
796) -> String {
797    setup_lines.push("class _TestVisitor implements TestVisitor {".to_string());
798    for (method_name, action) in &visitor_spec.callbacks {
799        emit_java_visitor_method(setup_lines, method_name, action, class_name);
800    }
801    setup_lines.push("}".to_string());
802    setup_lines.push("var visitor = new _TestVisitor();".to_string());
803    "visitor".to_string()
804}
805
806/// Emit a Java visitor method for a callback action.
807fn emit_java_visitor_method(
808    setup_lines: &mut Vec<String>,
809    method_name: &str,
810    action: &CallbackAction,
811    _class_name: &str,
812) {
813    let camel_method = method_to_camel(method_name);
814    let params = match method_name {
815        "visit_link" => "VisitContext ctx, String href, String text, String title",
816        "visit_image" => "VisitContext ctx, String src, String alt, String title",
817        "visit_heading" => "VisitContext ctx, int level, String text, String id",
818        "visit_code_block" => "VisitContext ctx, String lang, String code",
819        "visit_code_inline"
820        | "visit_strong"
821        | "visit_emphasis"
822        | "visit_strikethrough"
823        | "visit_underline"
824        | "visit_subscript"
825        | "visit_superscript"
826        | "visit_mark"
827        | "visit_button"
828        | "visit_summary"
829        | "visit_figcaption"
830        | "visit_definition_term"
831        | "visit_definition_description" => "VisitContext ctx, String text",
832        "visit_text" => "VisitContext ctx, String text",
833        "visit_list_item" => "VisitContext ctx, boolean ordered, String marker, String text",
834        "visit_blockquote" => "VisitContext ctx, String content, int depth",
835        "visit_table_row" => "VisitContext ctx, java.util.List<String> cells, boolean isHeader",
836        "visit_custom_element" => "VisitContext ctx, String tagName, String html",
837        "visit_form" => "VisitContext ctx, String actionUrl, String method",
838        "visit_input" => "VisitContext ctx, String inputType, String name, String value",
839        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, String src",
840        "visit_details" => "VisitContext ctx, boolean isOpen",
841        _ => "VisitContext ctx",
842    };
843
844    setup_lines.push(format!("    @Override public VisitResult {camel_method}({params}) {{"));
845    match action {
846        CallbackAction::Skip => {
847            setup_lines.push("        return VisitResult.skip();".to_string());
848        }
849        CallbackAction::Continue => {
850            setup_lines.push("        return VisitResult.continue_();".to_string());
851        }
852        CallbackAction::PreserveHtml => {
853            setup_lines.push("        return VisitResult.preserveHtml();".to_string());
854        }
855        CallbackAction::Custom { output } => {
856            let escaped = escape_java(output);
857            setup_lines.push(format!("        return VisitResult.custom(\"{escaped}\");"));
858        }
859        CallbackAction::CustomTemplate { template } => {
860            setup_lines.push(format!(
861                "        return VisitResult.custom(String.format(\"{template}\"));"
862            ));
863        }
864    }
865    setup_lines.push("    }".to_string());
866}
867
868/// Convert snake_case method names to Java camelCase.
869fn method_to_camel(snake: &str) -> String {
870    use heck::ToLowerCamelCase;
871    snake.to_lower_camel_case()
872}