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::fmt::Write as FmtWrite;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18
19pub struct JavaCodegen;
21
22impl E2eCodegen for JavaCodegen {
23 fn generate(
24 &self,
25 groups: &[FixtureGroup],
26 e2e_config: &E2eConfig,
27 alef_config: &AlefConfig,
28 ) -> Result<Vec<GeneratedFile>> {
29 let lang = self.language_name();
30 let output_base = PathBuf::from(&e2e_config.output).join(lang);
31
32 let mut files = Vec::new();
33
34 let call = &e2e_config.call;
36 let overrides = call.overrides.get(lang);
37 let module_path = overrides
38 .and_then(|o| o.module.as_ref())
39 .cloned()
40 .unwrap_or_else(|| call.module.clone());
41 let function_name = overrides
42 .and_then(|o| o.function.as_ref())
43 .cloned()
44 .unwrap_or_else(|| call.function.clone());
45 let class_name = overrides
46 .and_then(|o| o.class.as_ref())
47 .cloned()
48 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
49 let result_var = &call.result_var;
50
51 let java_pkg = e2e_config.packages.get("java");
53 let pkg_name = java_pkg
54 .and_then(|p| p.name.as_ref())
55 .cloned()
56 .unwrap_or_else(|| alef_config.crate_config.name.clone());
57
58 files.push(GeneratedFile {
60 path: output_base.join("pom.xml"),
61 content: render_pom_xml(&pkg_name),
62 generated_header: false,
63 });
64
65 let test_base = output_base
67 .join("src")
68 .join("test")
69 .join("java")
70 .join("dev")
71 .join("kreuzberg")
72 .join("e2e");
73
74 let options_type = overrides.and_then(|o| o.options_type.clone());
76 let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
77
78 for group in groups {
79 let active: Vec<&Fixture> = group
80 .fixtures
81 .iter()
82 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
83 .collect();
84
85 if active.is_empty() {
86 continue;
87 }
88
89 let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
90 let content = render_test_file(
91 &group.category,
92 &active,
93 &module_path,
94 &class_name,
95 &function_name,
96 result_var,
97 &e2e_config.call.args,
98 options_type.as_deref(),
99 &field_resolver,
100 );
101 files.push(GeneratedFile {
102 path: test_base.join(class_file_name),
103 content,
104 generated_header: true,
105 });
106 }
107
108 Ok(files)
109 }
110
111 fn language_name(&self) -> &'static str {
112 "java"
113 }
114}
115
116fn render_pom_xml(pkg_name: &str) -> String {
121 let artifact_id = format!("{pkg_name}-e2e-java");
122 format!(
123 r#"<?xml version="1.0" encoding="UTF-8"?>
124<project xmlns="http://maven.apache.org/POM/4.0.0"
125 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
126 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
127 <modelVersion>4.0.0</modelVersion>
128
129 <groupId>dev.kreuzberg</groupId>
130 <artifactId>{artifact_id}</artifactId>
131 <version>0.1.0</version>
132
133 <properties>
134 <maven.compiler.source>21</maven.compiler.source>
135 <maven.compiler.target>21</maven.compiler.target>
136 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
137 <junit.version>5.11.4</junit.version>
138 </properties>
139
140 <dependencies>
141 <dependency>
142 <groupId>org.junit.jupiter</groupId>
143 <artifactId>junit-jupiter</artifactId>
144 <version>${{junit.version}}</version>
145 <scope>test</scope>
146 </dependency>
147 </dependencies>
148
149 <build>
150 <plugins>
151 <plugin>
152 <groupId>org.codehaus.mojo</groupId>
153 <artifactId>build-helper-maven-plugin</artifactId>
154 <version>3.6.0</version>
155 <executions>
156 <execution>
157 <id>add-test-source</id>
158 <phase>generate-test-sources</phase>
159 <goals>
160 <goal>add-test-source</goal>
161 </goals>
162 <configuration>
163 <sources>
164 <source>src/test/java</source>
165 </sources>
166 </configuration>
167 </execution>
168 </executions>
169 </plugin>
170 <plugin>
171 <groupId>org.apache.maven.plugins</groupId>
172 <artifactId>maven-surefire-plugin</artifactId>
173 <version>3.5.2</version>
174 <configuration>
175 <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=../../target/release</argLine>
176 </configuration>
177 </plugin>
178 </plugins>
179 </build>
180</project>
181"#
182 )
183}
184
185#[allow(clippy::too_many_arguments)]
186fn render_test_file(
187 category: &str,
188 fixtures: &[&Fixture],
189 import_class: &str,
190 class_name: &str,
191 function_name: &str,
192 result_var: &str,
193 args: &[crate::config::ArgMapping],
194 options_type: Option<&str>,
195 field_resolver: &FieldResolver,
196) -> String {
197 let mut out = String::new();
198 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
199
200 let _ = writeln!(out, "package dev.kreuzberg.e2e;");
201 let _ = writeln!(out);
202
203 let needs_object_mapper = options_type.is_some()
205 && fixtures.iter().any(|f| {
206 args.iter()
207 .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
208 });
209
210 let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
211 let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
212 if !import_class.is_empty() {
213 let _ = writeln!(out, "import {import_class};");
214 }
215 if needs_object_mapper {
216 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
217 }
218 let _ = writeln!(out);
219
220 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
221 let _ = writeln!(out, "class {test_class_name} {{");
222
223 if needs_object_mapper {
224 let _ = writeln!(out);
225 let _ = writeln!(
226 out,
227 " private static final ObjectMapper MAPPER = new ObjectMapper();"
228 );
229 }
230
231 for fixture in fixtures {
232 render_test_method(
233 &mut out,
234 fixture,
235 class_name,
236 function_name,
237 result_var,
238 args,
239 options_type,
240 field_resolver,
241 );
242 let _ = writeln!(out);
243 }
244
245 let _ = writeln!(out, "}}");
246 out
247}
248
249fn render_test_method(
250 out: &mut String,
251 fixture: &Fixture,
252 class_name: &str,
253 function_name: &str,
254 result_var: &str,
255 args: &[crate::config::ArgMapping],
256 options_type: Option<&str>,
257 field_resolver: &FieldResolver,
258) {
259 let method_name = fixture.id.to_upper_camel_case();
260 let description = &fixture.description;
261 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
262
263 let needs_deser = options_type.is_some()
265 && args
266 .iter()
267 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
268
269 let throws_clause = if needs_deser { " throws Exception" } else { "" };
270
271 let _ = writeln!(out, " @Test");
272 let _ = writeln!(out, " void test{method_name}(){throws_clause} {{");
273 let _ = writeln!(out, " // {description}");
274
275 if let (true, Some(opts_type)) = (needs_deser, options_type) {
277 for arg in args {
278 if arg.arg_type == "json_object" {
279 if let Some(val) = fixture.input.get(&arg.field) {
280 if !val.is_null() {
281 let json_str = serde_json::to_string(val).unwrap_or_default();
282 let var_name = &arg.name;
283 let _ = writeln!(
284 out,
285 " var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
286 escape_java(&json_str)
287 );
288 }
289 }
290 }
291 }
292 }
293
294 let args_str = build_args_string(&fixture.input, args, options_type);
295
296 if expects_error {
297 let _ = writeln!(
298 out,
299 " assertThrows(Exception.class, () -> {class_name}.{function_name}({args_str}));"
300 );
301 let _ = writeln!(out, " }}");
302 return;
303 }
304
305 let _ = writeln!(
306 out,
307 " var {result_var} = {class_name}.{function_name}({args_str});"
308 );
309
310 for assertion in &fixture.assertions {
311 render_assertion(out, assertion, result_var, field_resolver);
312 }
313
314 let _ = writeln!(out, " }}");
315}
316
317fn build_args_string(
318 input: &serde_json::Value,
319 args: &[crate::config::ArgMapping],
320 options_type: Option<&str>,
321) -> String {
322 if args.is_empty() {
323 return json_to_java(input);
324 }
325
326 let parts: Vec<String> = args
327 .iter()
328 .filter_map(|arg| {
329 let val = input.get(&arg.field)?;
330 if val.is_null() && arg.optional {
331 return None;
332 }
333 if arg.arg_type == "json_object" && options_type.is_some() {
335 return Some(arg.name.clone());
336 }
337 Some(json_to_java(val))
338 })
339 .collect();
340
341 parts.join(", ")
342}
343
344fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
345 let field_expr = match &assertion.field {
346 Some(f) if !f.is_empty() => field_resolver.accessor(f, "java", result_var),
347 _ => result_var.to_string(),
348 };
349
350 match assertion.assertion_type.as_str() {
351 "equals" => {
352 if let Some(expected) = &assertion.value {
353 let java_val = json_to_java(expected);
354 let _ = writeln!(out, " assertEquals({java_val}, {field_expr}.strip());");
355 }
356 }
357 "contains" => {
358 if let Some(expected) = &assertion.value {
359 let java_val = json_to_java(expected);
360 let _ = writeln!(
361 out,
362 " assertTrue({field_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
363 );
364 }
365 }
366 "contains_all" => {
367 if let Some(values) = &assertion.values {
368 for val in values {
369 let java_val = json_to_java(val);
370 let _ = writeln!(
371 out,
372 " assertTrue({field_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
373 );
374 }
375 }
376 }
377 "not_contains" => {
378 if let Some(expected) = &assertion.value {
379 let java_val = json_to_java(expected);
380 let _ = writeln!(
381 out,
382 " assertFalse({field_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
383 );
384 }
385 }
386 "not_empty" => {
387 let _ = writeln!(
388 out,
389 " assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
390 );
391 }
392 "is_empty" => {
393 let _ = writeln!(
394 out,
395 " assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
396 );
397 }
398 "starts_with" => {
399 if let Some(expected) = &assertion.value {
400 let java_val = json_to_java(expected);
401 let _ = writeln!(
402 out,
403 " assertTrue({field_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
404 );
405 }
406 }
407 "ends_with" => {
408 if let Some(expected) = &assertion.value {
409 let java_val = json_to_java(expected);
410 let _ = writeln!(
411 out,
412 " assertTrue({field_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
413 );
414 }
415 }
416 "min_length" => {
417 if let Some(val) = &assertion.value {
418 if let Some(n) = val.as_u64() {
419 let _ = writeln!(
420 out,
421 " assertTrue({field_expr}.length() >= {n}, \"expected length >= {n}\");"
422 );
423 }
424 }
425 }
426 "max_length" => {
427 if let Some(val) = &assertion.value {
428 if let Some(n) = val.as_u64() {
429 let _ = writeln!(
430 out,
431 " assertTrue({field_expr}.length() <= {n}, \"expected length <= {n}\");"
432 );
433 }
434 }
435 }
436 "not_error" => {
437 }
439 "error" => {
440 }
442 other => {
443 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
444 }
445 }
446}
447
448fn json_to_java(value: &serde_json::Value) -> String {
450 match value {
451 serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
452 serde_json::Value::Bool(b) => b.to_string(),
453 serde_json::Value::Number(n) => {
454 if n.is_f64() {
455 format!("{}d", n)
456 } else {
457 n.to_string()
458 }
459 }
460 serde_json::Value::Null => "null".to_string(),
461 serde_json::Value::Array(arr) => {
462 let items: Vec<String> = arr.iter().map(json_to_java).collect();
463 format!("java.util.List.of({})", items.join(", "))
464 }
465 serde_json::Value::Object(_) => {
466 let json_str = serde_json::to_string(value).unwrap_or_default();
467 format!("\"{}\"", escape_java(&json_str))
468 }
469 }
470}