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