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