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 alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions::{maven, toolchain};
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;
21
22pub struct KotlinE2eCodegen;
24
25impl E2eCodegen for KotlinE2eCodegen {
26 fn generate(
27 &self,
28 groups: &[FixtureGroup],
29 e2e_config: &E2eConfig,
30 alef_config: &AlefConfig,
31 ) -> Result<Vec<GeneratedFile>> {
32 let lang = self.language_name();
33 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35 let mut files = Vec::new();
36
37 let call = &e2e_config.call;
39 let overrides = call.overrides.get(lang);
40 let _module_path = overrides
41 .and_then(|o| o.module.as_ref())
42 .cloned()
43 .unwrap_or_else(|| call.module.clone());
44 let function_name = overrides
45 .and_then(|o| o.function.as_ref())
46 .cloned()
47 .unwrap_or_else(|| call.function.clone());
48 let class_name = overrides
49 .and_then(|o| o.class.as_ref())
50 .cloned()
51 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
52 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
53 let result_var = &call.result_var;
54
55 let kotlin_pkg = e2e_config.resolve_package("kotlin");
57 let pkg_name = kotlin_pkg
58 .as_ref()
59 .and_then(|p| p.name.as_ref())
60 .cloned()
61 .unwrap_or_else(|| alef_config.crate_config.name.clone());
62
63 let _kotlin_pkg_path = kotlin_pkg
65 .as_ref()
66 .and_then(|p| p.path.as_ref())
67 .cloned()
68 .unwrap_or_else(|| "../../packages/kotlin".to_string());
69 let kotlin_version = kotlin_pkg
70 .as_ref()
71 .and_then(|p| p.version.as_ref())
72 .cloned()
73 .unwrap_or_else(|| "0.1.0".to_string());
74
75 files.push(GeneratedFile {
77 path: output_base.join("build.gradle.kts"),
78 content: render_build_gradle(&pkg_name, &kotlin_version, e2e_config.dep_mode),
79 generated_header: false,
80 });
81
82 let test_base = output_base
84 .join("src")
85 .join("test")
86 .join("kotlin")
87 .join("dev")
88 .join("kreuzberg")
89 .join("e2e");
90
91 let options_type = overrides.and_then(|o| o.options_type.clone());
93 let field_resolver = FieldResolver::new(
94 &e2e_config.fields,
95 &e2e_config.fields_optional,
96 &e2e_config.result_fields,
97 &e2e_config.fields_array,
98 );
99
100 for group in groups {
101 let active: Vec<&Fixture> = group
102 .fixtures
103 .iter()
104 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
105 .collect();
106
107 if active.is_empty() {
108 continue;
109 }
110
111 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
112 let content = render_test_file(
113 &group.category,
114 &active,
115 &class_name,
116 &function_name,
117 result_var,
118 &e2e_config.call.args,
119 options_type.as_deref(),
120 &field_resolver,
121 result_is_simple,
122 &e2e_config.fields_enum,
123 e2e_config,
124 );
125 files.push(GeneratedFile {
126 path: test_base.join(class_file_name),
127 content,
128 generated_header: true,
129 });
130 }
131
132 Ok(files)
133 }
134
135 fn language_name(&self) -> &'static str {
136 "kotlin"
137 }
138}
139
140fn render_build_gradle(pkg_name: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
145 let dep_block = match dep_mode {
146 crate::config::DependencyMode::Registry => {
147 format!(r#" testImplementation("{pkg_name}:{pkg_version}")"#)
148 }
149 crate::config::DependencyMode::Local => {
150 format!(r#" testImplementation(files("../../packages/kotlin/build/libs/{pkg_name}-{pkg_version}.jar"))"#)
152 }
153 };
154
155 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
156 let junit = maven::JUNIT;
157 let jackson = maven::JACKSON_E2E;
158 let jvm_target = toolchain::JVM_TARGET;
159 format!(
160 r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
161
162plugins {{
163 kotlin("jvm") version "{kotlin_plugin}"
164}}
165
166group = "dev.kreuzberg"
167version = "0.1.0"
168
169java {{
170 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
171 targetCompatibility = JavaVersion.VERSION_{jvm_target}
172}}
173
174kotlin {{
175 compilerOptions {{
176 jvmTarget.set(JvmTarget.JVM_{jvm_target})
177 }}
178}}
179
180repositories {{
181 mavenCentral()
182}}
183
184dependencies {{
185{dep_block}
186 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
187 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
188 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
189 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
190}}
191
192tasks.test {{
193 useJUnitPlatform()
194 environment("java.library.path", "../../target/release")
195}}
196"#
197 )
198}
199
200#[allow(clippy::too_many_arguments)]
201fn render_test_file(
202 category: &str,
203 fixtures: &[&Fixture],
204 class_name: &str,
205 function_name: &str,
206 result_var: &str,
207 args: &[crate::config::ArgMapping],
208 options_type: Option<&str>,
209 field_resolver: &FieldResolver,
210 result_is_simple: bool,
211 enum_fields: &HashSet<String>,
212 e2e_config: &E2eConfig,
213) -> String {
214 let mut out = String::new();
215 out.push_str(&hash::header(CommentStyle::DoubleSlash));
216 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
217
218 let (import_path, simple_class) = if class_name.contains('.') {
221 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
222 (class_name, simple)
223 } else {
224 ("", class_name)
225 };
226
227 let _ = writeln!(out, "package dev.kreuzberg.e2e");
228 let _ = writeln!(out);
229
230 let needs_object_mapper_for_options = options_type.is_some()
232 && fixtures.iter().any(|f| {
233 args.iter().any(|arg| {
234 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
235 arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
236 })
237 });
238 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
240 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
241 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
242 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
243 })
244 });
245 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
246
247 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
248 let _ = writeln!(out, "import kotlin.test.assertEquals");
249 let _ = writeln!(out, "import kotlin.test.assertTrue");
250 let _ = writeln!(out, "import kotlin.test.assertFalse");
251 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
252 if !import_path.is_empty() {
253 let _ = writeln!(out, "import {import_path}");
254 }
255 if needs_object_mapper {
256 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
257 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
258 }
259 if let Some(opts_type) = options_type {
261 if needs_object_mapper {
262 let opts_package = if !import_path.is_empty() {
264 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
265 format!("{pkg}.{opts_type}")
266 } else {
267 opts_type.to_string()
268 };
269 let _ = writeln!(out, "import {opts_package}");
270 }
271 }
272 if needs_object_mapper_for_handle && !import_path.is_empty() {
274 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
275 let _ = writeln!(out, "import {pkg}.CrawlConfig");
276 }
277 let _ = writeln!(out);
278
279 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
280 let _ = writeln!(out, "class {test_class_name} {{");
281
282 if needs_object_mapper {
283 let _ = writeln!(out);
284 let _ = writeln!(out, " companion object {{");
285 let _ = writeln!(
286 out,
287 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
288 );
289 let _ = writeln!(out, " }}");
290 }
291
292 for fixture in fixtures {
293 render_test_method(
294 &mut out,
295 fixture,
296 simple_class,
297 function_name,
298 result_var,
299 args,
300 options_type,
301 field_resolver,
302 result_is_simple,
303 enum_fields,
304 e2e_config,
305 );
306 let _ = writeln!(out);
307 }
308
309 let _ = writeln!(out, "}}");
310 out
311}
312
313#[allow(clippy::too_many_arguments)]
314fn render_test_method(
315 out: &mut String,
316 fixture: &Fixture,
317 class_name: &str,
318 _function_name: &str,
319 _result_var: &str,
320 _args: &[crate::config::ArgMapping],
321 options_type: Option<&str>,
322 field_resolver: &FieldResolver,
323 result_is_simple: bool,
324 enum_fields: &HashSet<String>,
325 e2e_config: &E2eConfig,
326) {
327 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
329 let lang = "kotlin";
330 let call_overrides = call_config.overrides.get(lang);
331 let effective_function_name = call_overrides
332 .and_then(|o| o.function.as_ref())
333 .cloned()
334 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
335 let effective_result_var = &call_config.result_var;
336 let effective_args = &call_config.args;
337 let function_name = effective_function_name.as_str();
338 let result_var = effective_result_var.as_str();
339 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
340
341 let method_name = fixture.id.to_upper_camel_case();
342 let description = &fixture.description;
343 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
344
345 let needs_deser = options_type.is_some()
347 && args
348 .iter()
349 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
350
351 let _ = writeln!(out, " @Test");
352 let _ = writeln!(out, " fun test{method_name}() {{");
353 let _ = writeln!(out, " // {description}");
354
355 if let (true, Some(opts_type)) = (needs_deser, options_type) {
357 for arg in args {
358 if arg.arg_type == "json_object" {
359 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
360 if let Some(val) = fixture.input.get(field) {
361 if !val.is_null() {
362 let normalized = super::normalize_json_keys_to_snake_case(val);
363 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
364 let var_name = &arg.name;
365 let _ = writeln!(
366 out,
367 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
368 escape_java(&json_str)
369 );
370 }
371 }
372 }
373 }
374 }
375
376 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
377
378 for line in &setup_lines {
379 let _ = writeln!(out, " {line}");
380 }
381
382 if expects_error {
383 let _ = writeln!(
384 out,
385 " assertFailsWith<Exception> {{ {class_name}.{function_name}({args_str}) }}"
386 );
387 let _ = writeln!(out, " }}");
388 return;
389 }
390
391 let _ = writeln!(
392 out,
393 " val {result_var} = {class_name}.{function_name}({args_str})"
394 );
395
396 for assertion in &fixture.assertions {
397 render_assertion(
398 out,
399 assertion,
400 result_var,
401 class_name,
402 field_resolver,
403 result_is_simple,
404 enum_fields,
405 );
406 }
407
408 let _ = writeln!(out, " }}");
409}
410
411fn build_args_and_setup(
415 input: &serde_json::Value,
416 args: &[crate::config::ArgMapping],
417 class_name: &str,
418 options_type: Option<&str>,
419 fixture_id: &str,
420) -> (Vec<String>, String) {
421 if args.is_empty() {
422 return (Vec::new(), String::new());
423 }
424
425 let mut setup_lines: Vec<String> = Vec::new();
426 let mut parts: Vec<String> = Vec::new();
427
428 for arg in args {
429 if arg.arg_type == "mock_url" {
430 setup_lines.push(format!(
431 "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
432 arg.name,
433 ));
434 parts.push(arg.name.clone());
435 continue;
436 }
437
438 if arg.arg_type == "handle" {
439 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
440 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
441 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
442 if config_value.is_null()
443 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
444 {
445 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
446 } else {
447 let json_str = serde_json::to_string(config_value).unwrap_or_default();
448 let name = &arg.name;
449 setup_lines.push(format!(
450 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
451 escape_java(&json_str),
452 ));
453 setup_lines.push(format!(
454 "val {} = {class_name}.{constructor_name}({name}Config)",
455 arg.name,
456 name = name,
457 ));
458 }
459 parts.push(arg.name.clone());
460 continue;
461 }
462
463 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
464 let val = input.get(field);
465 match val {
466 None | Some(serde_json::Value::Null) if arg.optional => {
467 continue;
468 }
469 None | Some(serde_json::Value::Null) => {
470 let default_val = match arg.arg_type.as_str() {
471 "string" => "\"\"".to_string(),
472 "int" | "integer" => "0".to_string(),
473 "float" | "number" => "0.0".to_string(),
474 "bool" | "boolean" => "false".to_string(),
475 _ => "null".to_string(),
476 };
477 parts.push(default_val);
478 }
479 Some(v) => {
480 if arg.arg_type == "json_object" && options_type.is_some() {
482 parts.push(arg.name.clone());
483 continue;
484 }
485 if arg.arg_type == "bytes" {
487 let val = json_to_kotlin(v);
488 parts.push(format!("{val}.toByteArray()"));
489 continue;
490 }
491 parts.push(json_to_kotlin(v));
492 }
493 }
494 }
495
496 (setup_lines, parts.join(", "))
497}
498
499fn render_assertion(
500 out: &mut String,
501 assertion: &Assertion,
502 result_var: &str,
503 _class_name: &str,
504 field_resolver: &FieldResolver,
505 result_is_simple: bool,
506 enum_fields: &HashSet<String>,
507) {
508 if let Some(f) = &assertion.field {
510 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
511 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
512 return;
513 }
514 }
515
516 let field_is_enum = assertion
518 .field
519 .as_deref()
520 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
521
522 let field_expr = if result_is_simple {
523 result_var.to_string()
524 } else {
525 match &assertion.field {
526 Some(f) if !f.is_empty() => {
527 let accessor = field_resolver.accessor(f, "kotlin", result_var);
528 let resolved = field_resolver.resolve(f);
529 if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
531 format!("{accessor}.orEmpty()")
532 } else {
533 accessor
534 }
535 }
536 _ => result_var.to_string(),
537 }
538 };
539
540 let string_expr = if field_is_enum {
542 format!("{field_expr}.getValue()")
543 } else {
544 field_expr.clone()
545 };
546
547 match assertion.assertion_type.as_str() {
548 "equals" => {
549 if let Some(expected) = &assertion.value {
550 let kotlin_val = json_to_kotlin(expected);
551 if expected.is_string() {
552 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
553 } else {
554 let _ = writeln!(out, " assertEquals({kotlin_val}, {field_expr})");
555 }
556 }
557 }
558 "contains" => {
559 if let Some(expected) = &assertion.value {
560 let kotlin_val = json_to_kotlin(expected);
561 let _ = writeln!(
562 out,
563 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
564 );
565 }
566 }
567 "contains_all" => {
568 if let Some(values) = &assertion.values {
569 for val in values {
570 let kotlin_val = json_to_kotlin(val);
571 let _ = writeln!(
572 out,
573 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
574 );
575 }
576 }
577 }
578 "not_contains" => {
579 if let Some(expected) = &assertion.value {
580 let kotlin_val = json_to_kotlin(expected);
581 let _ = writeln!(
582 out,
583 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
584 );
585 }
586 }
587 "not_empty" => {
588 let _ = writeln!(
589 out,
590 " assertFalse({field_expr}.isEmpty(), \"expected non-empty value\")"
591 );
592 }
593 "is_empty" => {
594 let _ = writeln!(
595 out,
596 " assertTrue({field_expr}.isEmpty(), \"expected empty value\")"
597 );
598 }
599 "contains_any" => {
600 if let Some(values) = &assertion.values {
601 let checks: Vec<String> = values
602 .iter()
603 .map(|v| {
604 let kotlin_val = json_to_kotlin(v);
605 format!("{string_expr}.contains({kotlin_val})")
606 })
607 .collect();
608 let joined = checks.join(" || ");
609 let _ = writeln!(
610 out,
611 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
612 );
613 }
614 }
615 "greater_than" => {
616 if let Some(val) = &assertion.value {
617 let kotlin_val = json_to_kotlin(val);
618 let _ = writeln!(
619 out,
620 " assertTrue({field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
621 );
622 }
623 }
624 "less_than" => {
625 if let Some(val) = &assertion.value {
626 let kotlin_val = json_to_kotlin(val);
627 let _ = writeln!(
628 out,
629 " assertTrue({field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
630 );
631 }
632 }
633 "greater_than_or_equal" => {
634 if let Some(val) = &assertion.value {
635 let kotlin_val = json_to_kotlin(val);
636 let _ = writeln!(
637 out,
638 " assertTrue({field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
639 );
640 }
641 }
642 "less_than_or_equal" => {
643 if let Some(val) = &assertion.value {
644 let kotlin_val = json_to_kotlin(val);
645 let _ = writeln!(
646 out,
647 " assertTrue({field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
648 );
649 }
650 }
651 "starts_with" => {
652 if let Some(expected) = &assertion.value {
653 let kotlin_val = json_to_kotlin(expected);
654 let _ = writeln!(
655 out,
656 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
657 );
658 }
659 }
660 "ends_with" => {
661 if let Some(expected) = &assertion.value {
662 let kotlin_val = json_to_kotlin(expected);
663 let _ = writeln!(
664 out,
665 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
666 );
667 }
668 }
669 "min_length" => {
670 if let Some(val) = &assertion.value {
671 if let Some(n) = val.as_u64() {
672 let _ = writeln!(
673 out,
674 " assertTrue({field_expr}.length >= {n}, \"expected length >= {n}\")"
675 );
676 }
677 }
678 }
679 "max_length" => {
680 if let Some(val) = &assertion.value {
681 if let Some(n) = val.as_u64() {
682 let _ = writeln!(
683 out,
684 " assertTrue({field_expr}.length <= {n}, \"expected length <= {n}\")"
685 );
686 }
687 }
688 }
689 "count_min" => {
690 if let Some(val) = &assertion.value {
691 if let Some(n) = val.as_u64() {
692 let _ = writeln!(
693 out,
694 " assertTrue({field_expr}.size >= {n}, \"expected at least {n} elements\")"
695 );
696 }
697 }
698 }
699 "count_equals" => {
700 if let Some(val) = &assertion.value {
701 if let Some(n) = val.as_u64() {
702 let _ = writeln!(
703 out,
704 " assertEquals({n}, {field_expr}.size, \"expected exactly {n} elements\")"
705 );
706 }
707 }
708 }
709 "is_true" => {
710 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
711 }
712 "is_false" => {
713 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
714 }
715 "matches_regex" => {
716 if let Some(expected) = &assertion.value {
717 let kotlin_val = json_to_kotlin(expected);
718 let _ = writeln!(
719 out,
720 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
721 );
722 }
723 }
724 "not_error" => {
725 }
727 "error" => {
728 }
730 "method_result" => {
731 let _ = writeln!(
733 out,
734 " // method_result assertions not yet implemented for Kotlin"
735 );
736 }
737 other => {
738 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
739 }
740 }
741}
742
743fn json_to_kotlin(value: &serde_json::Value) -> String {
745 match value {
746 serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
747 serde_json::Value::Bool(b) => b.to_string(),
748 serde_json::Value::Number(n) => {
749 if n.is_f64() {
750 format!("{}d", n)
751 } else {
752 n.to_string()
753 }
754 }
755 serde_json::Value::Null => "null".to_string(),
756 serde_json::Value::Array(arr) => {
757 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
758 format!("listOf({})", items.join(", "))
759 }
760 serde_json::Value::Object(_) => {
761 let json_str = serde_json::to_string(value).unwrap_or_default();
762 format!("\"{}\"", escape_java(&json_str))
763 }
764 }
765}