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.effective_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.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 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 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 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 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
129fn 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 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 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 let needs_object_mapper_for_options = options_type.is_some()
273 && fixtures.iter().any(|f| {
274 args.iter()
275 .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
276 });
277 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
279 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
280 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
281 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
282 })
283 });
284 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
285
286 let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
287 let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
288 if !import_path.is_empty() {
289 let _ = writeln!(out, "import {import_path};");
290 }
291 if needs_object_mapper {
292 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
293 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
294 }
295 if let Some(opts_type) = options_type {
297 if needs_object_mapper {
298 let opts_package = if !import_path.is_empty() {
300 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
301 format!("{pkg}.{opts_type}")
302 } else {
303 opts_type.to_string()
304 };
305 let _ = writeln!(out, "import {opts_package};");
306 }
307 }
308 if needs_object_mapper_for_handle && !import_path.is_empty() {
310 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
311 let _ = writeln!(out, "import {pkg}.CrawlConfig;");
312 }
313 let _ = writeln!(out);
314
315 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
316 let _ = writeln!(out, "class {test_class_name} {{");
317
318 if needs_object_mapper {
319 let _ = writeln!(out);
320 let _ = writeln!(
321 out,
322 " private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
323 );
324 }
325
326 for fixture in fixtures {
327 render_test_method(
328 &mut out,
329 fixture,
330 simple_class,
331 function_name,
332 result_var,
333 args,
334 options_type,
335 field_resolver,
336 result_is_simple,
337 enum_fields,
338 );
339 let _ = writeln!(out);
340 }
341
342 let _ = writeln!(out, "}}");
343 out
344}
345
346#[allow(clippy::too_many_arguments)]
347fn render_test_method(
348 out: &mut String,
349 fixture: &Fixture,
350 class_name: &str,
351 function_name: &str,
352 result_var: &str,
353 args: &[crate::config::ArgMapping],
354 options_type: Option<&str>,
355 field_resolver: &FieldResolver,
356 result_is_simple: bool,
357 enum_fields: &HashSet<String>,
358) {
359 let method_name = fixture.id.to_upper_camel_case();
360 let description = &fixture.description;
361 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
362
363 let needs_deser = options_type.is_some()
365 && args
366 .iter()
367 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
368
369 let throws_clause = " throws Exception";
371
372 let _ = writeln!(out, " @Test");
373 let _ = writeln!(out, " void test{method_name}(){throws_clause} {{");
374 let _ = writeln!(out, " // {description}");
375
376 if let (true, Some(opts_type)) = (needs_deser, options_type) {
378 for arg in args {
379 if arg.arg_type == "json_object" {
380 if let Some(val) = fixture.input.get(&arg.field) {
381 if !val.is_null() {
382 let normalized = super::normalize_json_keys_to_snake_case(val);
386 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
387 let var_name = &arg.name;
388 let _ = writeln!(
389 out,
390 " var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
391 escape_java(&json_str)
392 );
393 }
394 }
395 }
396 }
397 }
398
399 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
400
401 for line in &setup_lines {
402 let _ = writeln!(out, " {line}");
403 }
404
405 if expects_error {
406 let _ = writeln!(
407 out,
408 " assertThrows(Exception.class, () -> {class_name}.{function_name}({args_str}));"
409 );
410 let _ = writeln!(out, " }}");
411 return;
412 }
413
414 let _ = writeln!(
415 out,
416 " var {result_var} = {class_name}.{function_name}({args_str});"
417 );
418
419 for assertion in &fixture.assertions {
420 render_assertion(
421 out,
422 assertion,
423 result_var,
424 field_resolver,
425 result_is_simple,
426 enum_fields,
427 );
428 }
429
430 let _ = writeln!(out, " }}");
431}
432
433fn build_args_and_setup(
437 input: &serde_json::Value,
438 args: &[crate::config::ArgMapping],
439 class_name: &str,
440 options_type: Option<&str>,
441 fixture_id: &str,
442) -> (Vec<String>, String) {
443 if args.is_empty() {
444 return (Vec::new(), json_to_java(input));
445 }
446
447 let mut setup_lines: Vec<String> = Vec::new();
448 let mut parts: Vec<String> = Vec::new();
449
450 for arg in args {
451 if arg.arg_type == "mock_url" {
452 setup_lines.push(format!(
453 "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
454 arg.name,
455 ));
456 parts.push(arg.name.clone());
457 continue;
458 }
459
460 if arg.arg_type == "handle" {
461 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
463 let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
464 if config_value.is_null()
465 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
466 {
467 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
468 } else {
469 let json_str = serde_json::to_string(config_value).unwrap_or_default();
470 let name = &arg.name;
471 setup_lines.push(format!(
472 "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
473 escape_java(&json_str),
474 ));
475 setup_lines.push(format!(
476 "var {} = {class_name}.{constructor_name}({name}Config);",
477 arg.name,
478 name = name,
479 ));
480 }
481 parts.push(arg.name.clone());
482 continue;
483 }
484
485 let val = input.get(&arg.field);
486 match val {
487 None | Some(serde_json::Value::Null) if arg.optional => {
488 continue;
490 }
491 None | Some(serde_json::Value::Null) => {
492 let default_val = match arg.arg_type.as_str() {
494 "string" => "\"\"".to_string(),
495 "int" | "integer" => "0".to_string(),
496 "float" | "number" => "0.0d".to_string(),
497 "bool" | "boolean" => "false".to_string(),
498 _ => "null".to_string(),
499 };
500 parts.push(default_val);
501 }
502 Some(v) => {
503 if arg.arg_type == "json_object" && options_type.is_some() {
505 parts.push(arg.name.clone());
506 continue;
507 }
508 parts.push(json_to_java(v));
509 }
510 }
511 }
512
513 (setup_lines, parts.join(", "))
514}
515
516fn render_assertion(
517 out: &mut String,
518 assertion: &Assertion,
519 result_var: &str,
520 field_resolver: &FieldResolver,
521 result_is_simple: bool,
522 enum_fields: &HashSet<String>,
523) {
524 if let Some(f) = &assertion.field {
526 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
527 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
528 return;
529 }
530 }
531
532 let field_is_enum = assertion
537 .field
538 .as_deref()
539 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
540
541 let field_expr = if result_is_simple {
542 result_var.to_string()
543 } else {
544 match &assertion.field {
545 Some(f) if !f.is_empty() => {
546 let accessor = field_resolver.accessor(f, "java", result_var);
547 let resolved = field_resolver.resolve(f);
548 if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
551 format!("{accessor}.orElse(\"\")")
552 } else {
553 accessor
554 }
555 }
556 _ => result_var.to_string(),
557 }
558 };
559
560 let string_expr = if field_is_enum {
564 format!("{field_expr}.getValue()")
565 } else {
566 field_expr.clone()
567 };
568
569 match assertion.assertion_type.as_str() {
570 "equals" => {
571 if let Some(expected) = &assertion.value {
572 let java_val = json_to_java(expected);
573 if expected.is_string() {
574 let _ = writeln!(out, " assertEquals({java_val}, {string_expr}.trim());");
575 } else {
576 let _ = writeln!(out, " assertEquals({java_val}, {field_expr});");
577 }
578 }
579 }
580 "contains" => {
581 if let Some(expected) = &assertion.value {
582 let java_val = json_to_java(expected);
583 let _ = writeln!(
584 out,
585 " assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
586 );
587 }
588 }
589 "contains_all" => {
590 if let Some(values) = &assertion.values {
591 for val in values {
592 let java_val = json_to_java(val);
593 let _ = writeln!(
594 out,
595 " assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
596 );
597 }
598 }
599 }
600 "not_contains" => {
601 if let Some(expected) = &assertion.value {
602 let java_val = json_to_java(expected);
603 let _ = writeln!(
604 out,
605 " assertFalse({string_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
606 );
607 }
608 }
609 "not_empty" => {
610 let _ = writeln!(
611 out,
612 " assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
613 );
614 }
615 "is_empty" => {
616 let _ = writeln!(
617 out,
618 " assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
619 );
620 }
621 "contains_any" => {
622 if let Some(values) = &assertion.values {
623 let checks: Vec<String> = values
624 .iter()
625 .map(|v| {
626 let java_val = json_to_java(v);
627 format!("{string_expr}.contains({java_val})")
628 })
629 .collect();
630 let joined = checks.join(" || ");
631 let _ = writeln!(
632 out,
633 " assertTrue({joined}, \"expected to contain at least one of the specified values\");"
634 );
635 }
636 }
637 "greater_than" => {
638 if let Some(val) = &assertion.value {
639 let java_val = json_to_java(val);
640 let _ = writeln!(
641 out,
642 " assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
643 );
644 }
645 }
646 "less_than" => {
647 if let Some(val) = &assertion.value {
648 let java_val = json_to_java(val);
649 let _ = writeln!(
650 out,
651 " assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
652 );
653 }
654 }
655 "greater_than_or_equal" => {
656 if let Some(val) = &assertion.value {
657 let java_val = json_to_java(val);
658 let _ = writeln!(
659 out,
660 " assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
661 );
662 }
663 }
664 "less_than_or_equal" => {
665 if let Some(val) = &assertion.value {
666 let java_val = json_to_java(val);
667 let _ = writeln!(
668 out,
669 " assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
670 );
671 }
672 }
673 "starts_with" => {
674 if let Some(expected) = &assertion.value {
675 let java_val = json_to_java(expected);
676 let _ = writeln!(
677 out,
678 " assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
679 );
680 }
681 }
682 "ends_with" => {
683 if let Some(expected) = &assertion.value {
684 let java_val = json_to_java(expected);
685 let _ = writeln!(
686 out,
687 " assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
688 );
689 }
690 }
691 "min_length" => {
692 if let Some(val) = &assertion.value {
693 if let Some(n) = val.as_u64() {
694 let _ = writeln!(
695 out,
696 " assertTrue({field_expr}.length() >= {n}, \"expected length >= {n}\");"
697 );
698 }
699 }
700 }
701 "max_length" => {
702 if let Some(val) = &assertion.value {
703 if let Some(n) = val.as_u64() {
704 let _ = writeln!(
705 out,
706 " assertTrue({field_expr}.length() <= {n}, \"expected length <= {n}\");"
707 );
708 }
709 }
710 }
711 "count_min" => {
712 if let Some(val) = &assertion.value {
713 if let Some(n) = val.as_u64() {
714 let _ = writeln!(
715 out,
716 " assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
717 );
718 }
719 }
720 }
721 "not_error" => {
722 }
724 "error" => {
725 }
727 other => {
728 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
729 }
730 }
731}
732
733fn json_to_java(value: &serde_json::Value) -> String {
735 match value {
736 serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
737 serde_json::Value::Bool(b) => b.to_string(),
738 serde_json::Value::Number(n) => {
739 if n.is_f64() {
740 format!("{}d", n)
741 } else {
742 n.to_string()
743 }
744 }
745 serde_json::Value::Null => "null".to_string(),
746 serde_json::Value::Array(arr) => {
747 let items: Vec<String> = arr.iter().map(json_to_java).collect();
748 format!("java.util.List.of({})", items.join(", "))
749 }
750 serde_json::Value::Object(_) => {
751 let json_str = serde_json::to_string(value).unwrap_or_default();
752 format!("\"{}\"", escape_java(&json_str))
753 }
754 }
755}