1use crate::config::E2eConfig;
7use crate::escape::{escape_java, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, 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
20pub 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.output).join(lang);
32
33 let mut files = Vec::new();
34
35 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 let java_pkg = e2e_config.packages.get("java");
55 let pkg_name = java_pkg
56 .and_then(|p| p.name.as_ref())
57 .cloned()
58 .unwrap_or_else(|| alef_config.crate_config.name.clone());
59
60 let java_group_id = alef_config.java_group_id();
62 let pkg_version = alef_config.resolved_version().unwrap_or_else(|| "0.1.0".to_string());
63
64 files.push(GeneratedFile {
66 path: output_base.join("pom.xml"),
67 content: render_pom_xml(&pkg_name, &java_group_id, &pkg_version),
68 generated_header: false,
69 });
70
71 let test_base = output_base
73 .join("src")
74 .join("test")
75 .join("java")
76 .join("dev")
77 .join("kreuzberg")
78 .join("e2e");
79
80 let options_type = overrides.and_then(|o| o.options_type.clone());
82 let field_resolver = FieldResolver::new(
83 &e2e_config.fields,
84 &e2e_config.fields_optional,
85 &e2e_config.result_fields,
86 &e2e_config.fields_array,
87 );
88
89 for group in groups {
90 let active: Vec<&Fixture> = group
91 .fixtures
92 .iter()
93 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
94 .collect();
95
96 if active.is_empty() {
97 continue;
98 }
99
100 let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
101 let content = render_test_file(
102 &group.category,
103 &active,
104 &class_name,
105 &function_name,
106 result_var,
107 &e2e_config.call.args,
108 options_type.as_deref(),
109 &field_resolver,
110 result_is_simple,
111 &e2e_config.fields_enum,
112 );
113 files.push(GeneratedFile {
114 path: test_base.join(class_file_name),
115 content,
116 generated_header: true,
117 });
118 }
119
120 Ok(files)
121 }
122
123 fn language_name(&self) -> &'static str {
124 "java"
125 }
126}
127
128fn render_pom_xml(pkg_name: &str, java_group_id: &str, pkg_version: &str) -> String {
133 let artifact_id = format!("{pkg_name}-e2e-java");
134 format!(
135 r#"<?xml version="1.0" encoding="UTF-8"?>
136<project xmlns="http://maven.apache.org/POM/4.0.0"
137 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
138 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
139 <modelVersion>4.0.0</modelVersion>
140
141 <groupId>dev.kreuzberg</groupId>
142 <artifactId>{artifact_id}</artifactId>
143 <version>0.1.0</version>
144
145 <properties>
146 <maven.compiler.source>25</maven.compiler.source>
147 <maven.compiler.target>25</maven.compiler.target>
148 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
149 <junit.version>5.11.4</junit.version>
150 </properties>
151
152 <dependencies>
153 <dependency>
154 <groupId>{java_group_id}</groupId>
155 <artifactId>{pkg_name}</artifactId>
156 <version>{pkg_version}</version>
157 <scope>system</scope>
158 <systemPath>${{project.basedir}}/../../packages/java/target/{pkg_name}-{pkg_version}.jar</systemPath>
159 </dependency>
160 <dependency>
161 <groupId>com.fasterxml.jackson.core</groupId>
162 <artifactId>jackson-databind</artifactId>
163 <version>2.18.2</version>
164 </dependency>
165 <dependency>
166 <groupId>com.fasterxml.jackson.datatype</groupId>
167 <artifactId>jackson-datatype-jdk8</artifactId>
168 <version>2.18.2</version>
169 </dependency>
170 <dependency>
171 <groupId>org.junit.jupiter</groupId>
172 <artifactId>junit-jupiter</artifactId>
173 <version>${{junit.version}}</version>
174 <scope>test</scope>
175 </dependency>
176 </dependencies>
177
178 <build>
179 <plugins>
180 <plugin>
181 <groupId>org.codehaus.mojo</groupId>
182 <artifactId>build-helper-maven-plugin</artifactId>
183 <version>3.6.0</version>
184 <executions>
185 <execution>
186 <id>add-test-source</id>
187 <phase>generate-test-sources</phase>
188 <goals>
189 <goal>add-test-source</goal>
190 </goals>
191 <configuration>
192 <sources>
193 <source>src/test/java</source>
194 </sources>
195 </configuration>
196 </execution>
197 </executions>
198 </plugin>
199 <plugin>
200 <groupId>org.apache.maven.plugins</groupId>
201 <artifactId>maven-surefire-plugin</artifactId>
202 <version>3.5.2</version>
203 <configuration>
204 <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=../../target/release</argLine>
205 </configuration>
206 </plugin>
207 </plugins>
208 </build>
209</project>
210"#
211 )
212}
213
214#[allow(clippy::too_many_arguments)]
215fn render_test_file(
216 category: &str,
217 fixtures: &[&Fixture],
218 class_name: &str,
219 function_name: &str,
220 result_var: &str,
221 args: &[crate::config::ArgMapping],
222 options_type: Option<&str>,
223 field_resolver: &FieldResolver,
224 result_is_simple: bool,
225 enum_fields: &HashSet<String>,
226) -> String {
227 let mut out = String::new();
228 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
229 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
230
231 let (import_path, simple_class) = if class_name.contains('.') {
234 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
235 (class_name, simple)
236 } else {
237 ("", class_name)
238 };
239
240 let _ = writeln!(out, "package dev.kreuzberg.e2e;");
241 let _ = writeln!(out);
242
243 let needs_object_mapper_for_options = options_type.is_some()
245 && fixtures.iter().any(|f| {
246 args.iter()
247 .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
248 });
249 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
251 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
252 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
253 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
254 })
255 });
256 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
257
258 let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
259 let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
260 if !import_path.is_empty() {
261 let _ = writeln!(out, "import {import_path};");
262 }
263 if needs_object_mapper {
264 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
265 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
266 }
267 if let Some(opts_type) = options_type {
269 if needs_object_mapper {
270 let opts_package = if !import_path.is_empty() {
272 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
273 format!("{pkg}.{opts_type}")
274 } else {
275 opts_type.to_string()
276 };
277 let _ = writeln!(out, "import {opts_package};");
278 }
279 }
280 if needs_object_mapper_for_handle && !import_path.is_empty() {
282 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
283 let _ = writeln!(out, "import {pkg}.CrawlConfig;");
284 }
285 let _ = writeln!(out);
286
287 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
288 let _ = writeln!(out, "class {test_class_name} {{");
289
290 if needs_object_mapper {
291 let _ = writeln!(out);
292 let _ = writeln!(
293 out,
294 " private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
295 );
296 }
297
298 for fixture in fixtures {
299 render_test_method(
300 &mut out,
301 fixture,
302 simple_class,
303 function_name,
304 result_var,
305 args,
306 options_type,
307 field_resolver,
308 result_is_simple,
309 enum_fields,
310 );
311 let _ = writeln!(out);
312 }
313
314 let _ = writeln!(out, "}}");
315 out
316}
317
318#[allow(clippy::too_many_arguments)]
319fn render_test_method(
320 out: &mut String,
321 fixture: &Fixture,
322 class_name: &str,
323 function_name: &str,
324 result_var: &str,
325 args: &[crate::config::ArgMapping],
326 options_type: Option<&str>,
327 field_resolver: &FieldResolver,
328 result_is_simple: bool,
329 enum_fields: &HashSet<String>,
330) {
331 let method_name = fixture.id.to_upper_camel_case();
332 let description = &fixture.description;
333 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
334
335 let needs_deser = options_type.is_some()
337 && args
338 .iter()
339 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
340
341 let throws_clause = " throws Exception";
343
344 let _ = writeln!(out, " @Test");
345 let _ = writeln!(out, " void test{method_name}(){throws_clause} {{");
346 let _ = writeln!(out, " // {description}");
347
348 if let (true, Some(opts_type)) = (needs_deser, options_type) {
350 for arg in args {
351 if arg.arg_type == "json_object" {
352 if let Some(val) = fixture.input.get(&arg.field) {
353 if !val.is_null() {
354 let normalized = super::normalize_json_keys_to_snake_case(val);
358 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
359 let var_name = &arg.name;
360 let _ = writeln!(
361 out,
362 " var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
363 escape_java(&json_str)
364 );
365 }
366 }
367 }
368 }
369 }
370
371 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
372
373 for line in &setup_lines {
374 let _ = writeln!(out, " {line}");
375 }
376
377 if expects_error {
378 let _ = writeln!(
379 out,
380 " assertThrows(Exception.class, () -> {class_name}.{function_name}({args_str}));"
381 );
382 let _ = writeln!(out, " }}");
383 return;
384 }
385
386 let _ = writeln!(
387 out,
388 " var {result_var} = {class_name}.{function_name}({args_str});"
389 );
390
391 for assertion in &fixture.assertions {
392 render_assertion(
393 out,
394 assertion,
395 result_var,
396 field_resolver,
397 result_is_simple,
398 enum_fields,
399 );
400 }
401
402 let _ = writeln!(out, " }}");
403}
404
405fn build_args_and_setup(
409 input: &serde_json::Value,
410 args: &[crate::config::ArgMapping],
411 class_name: &str,
412 options_type: Option<&str>,
413 fixture_id: &str,
414) -> (Vec<String>, String) {
415 if args.is_empty() {
416 return (Vec::new(), json_to_java(input));
417 }
418
419 let mut setup_lines: Vec<String> = Vec::new();
420 let mut parts: Vec<String> = Vec::new();
421
422 for arg in args {
423 if arg.arg_type == "mock_url" {
424 setup_lines.push(format!(
425 "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
426 arg.name,
427 ));
428 parts.push(arg.name.clone());
429 continue;
430 }
431
432 if arg.arg_type == "handle" {
433 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
435 let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
436 if config_value.is_null()
437 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
438 {
439 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
440 } else {
441 let json_str = serde_json::to_string(config_value).unwrap_or_default();
442 let name = &arg.name;
443 setup_lines.push(format!(
444 "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
445 escape_java(&json_str),
446 ));
447 setup_lines.push(format!(
448 "var {} = {class_name}.{constructor_name}({name}Config);",
449 arg.name,
450 name = name,
451 ));
452 }
453 parts.push(arg.name.clone());
454 continue;
455 }
456
457 let val = input.get(&arg.field);
458 match val {
459 None | Some(serde_json::Value::Null) if arg.optional => {
460 continue;
462 }
463 None | Some(serde_json::Value::Null) => {
464 let default_val = match arg.arg_type.as_str() {
466 "string" => "\"\"".to_string(),
467 "int" | "integer" => "0".to_string(),
468 "float" | "number" => "0.0d".to_string(),
469 "bool" | "boolean" => "false".to_string(),
470 _ => "null".to_string(),
471 };
472 parts.push(default_val);
473 }
474 Some(v) => {
475 if arg.arg_type == "json_object" && options_type.is_some() {
477 parts.push(arg.name.clone());
478 continue;
479 }
480 parts.push(json_to_java(v));
481 }
482 }
483 }
484
485 (setup_lines, parts.join(", "))
486}
487
488fn render_assertion(
489 out: &mut String,
490 assertion: &Assertion,
491 result_var: &str,
492 field_resolver: &FieldResolver,
493 result_is_simple: bool,
494 enum_fields: &HashSet<String>,
495) {
496 if let Some(f) = &assertion.field {
498 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
499 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
500 return;
501 }
502 }
503
504 let field_is_enum = assertion
509 .field
510 .as_deref()
511 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
512
513 let field_expr = if result_is_simple {
514 result_var.to_string()
515 } else {
516 match &assertion.field {
517 Some(f) if !f.is_empty() => {
518 let accessor = field_resolver.accessor(f, "java", result_var);
519 let resolved = field_resolver.resolve(f);
520 if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
523 format!("{accessor}.orElse(\"\")")
524 } else {
525 accessor
526 }
527 }
528 _ => result_var.to_string(),
529 }
530 };
531
532 let string_expr = if field_is_enum {
536 format!("{field_expr}.getValue()")
537 } else {
538 field_expr.clone()
539 };
540
541 match assertion.assertion_type.as_str() {
542 "equals" => {
543 if let Some(expected) = &assertion.value {
544 let java_val = json_to_java(expected);
545 if expected.is_string() {
546 let _ = writeln!(out, " assertEquals({java_val}, {string_expr}.trim());");
547 } else {
548 let _ = writeln!(out, " assertEquals({java_val}, {field_expr});");
549 }
550 }
551 }
552 "contains" => {
553 if let Some(expected) = &assertion.value {
554 let java_val = json_to_java(expected);
555 let _ = writeln!(
556 out,
557 " assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
558 );
559 }
560 }
561 "contains_all" => {
562 if let Some(values) = &assertion.values {
563 for val in values {
564 let java_val = json_to_java(val);
565 let _ = writeln!(
566 out,
567 " assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
568 );
569 }
570 }
571 }
572 "not_contains" => {
573 if let Some(expected) = &assertion.value {
574 let java_val = json_to_java(expected);
575 let _ = writeln!(
576 out,
577 " assertFalse({string_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
578 );
579 }
580 }
581 "not_empty" => {
582 let _ = writeln!(
583 out,
584 " assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
585 );
586 }
587 "is_empty" => {
588 let _ = writeln!(
589 out,
590 " assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
591 );
592 }
593 "contains_any" => {
594 if let Some(values) = &assertion.values {
595 let checks: Vec<String> = values
596 .iter()
597 .map(|v| {
598 let java_val = json_to_java(v);
599 format!("{string_expr}.contains({java_val})")
600 })
601 .collect();
602 let joined = checks.join(" || ");
603 let _ = writeln!(
604 out,
605 " assertTrue({joined}, \"expected to contain at least one of the specified values\");"
606 );
607 }
608 }
609 "greater_than" => {
610 if let Some(val) = &assertion.value {
611 let java_val = json_to_java(val);
612 let _ = writeln!(
613 out,
614 " assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
615 );
616 }
617 }
618 "less_than" => {
619 if let Some(val) = &assertion.value {
620 let java_val = json_to_java(val);
621 let _ = writeln!(
622 out,
623 " assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
624 );
625 }
626 }
627 "greater_than_or_equal" => {
628 if let Some(val) = &assertion.value {
629 let java_val = json_to_java(val);
630 let _ = writeln!(
631 out,
632 " assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
633 );
634 }
635 }
636 "less_than_or_equal" => {
637 if let Some(val) = &assertion.value {
638 let java_val = json_to_java(val);
639 let _ = writeln!(
640 out,
641 " assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
642 );
643 }
644 }
645 "starts_with" => {
646 if let Some(expected) = &assertion.value {
647 let java_val = json_to_java(expected);
648 let _ = writeln!(
649 out,
650 " assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
651 );
652 }
653 }
654 "ends_with" => {
655 if let Some(expected) = &assertion.value {
656 let java_val = json_to_java(expected);
657 let _ = writeln!(
658 out,
659 " assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
660 );
661 }
662 }
663 "min_length" => {
664 if let Some(val) = &assertion.value {
665 if let Some(n) = val.as_u64() {
666 let _ = writeln!(
667 out,
668 " assertTrue({field_expr}.length() >= {n}, \"expected length >= {n}\");"
669 );
670 }
671 }
672 }
673 "max_length" => {
674 if let Some(val) = &assertion.value {
675 if let Some(n) = val.as_u64() {
676 let _ = writeln!(
677 out,
678 " assertTrue({field_expr}.length() <= {n}, \"expected length <= {n}\");"
679 );
680 }
681 }
682 }
683 "count_min" => {
684 if let Some(val) = &assertion.value {
685 if let Some(n) = val.as_u64() {
686 let _ = writeln!(
687 out,
688 " assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
689 );
690 }
691 }
692 }
693 "not_error" => {
694 }
696 "error" => {
697 }
699 other => {
700 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
701 }
702 }
703}
704
705fn json_to_java(value: &serde_json::Value) -> String {
707 match value {
708 serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
709 serde_json::Value::Bool(b) => b.to_string(),
710 serde_json::Value::Number(n) => {
711 if n.is_f64() {
712 format!("{}d", n)
713 } else {
714 n.to_string()
715 }
716 }
717 serde_json::Value::Null => "null".to_string(),
718 serde_json::Value::Array(arr) => {
719 let items: Vec<String> = arr.iter().map(json_to_java).collect();
720 format!("java.util.List.of({})", items.join(", "))
721 }
722 serde_json::Value::Object(_) => {
723 let json_str = serde_json::to_string(value).unwrap_or_default();
724 format!("\"{}\"", escape_java(&json_str))
725 }
726 }
727}