1use crate::config::E2eConfig;
7use crate::escape::{escape_kotlin, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture};
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 testImplementation(kotlin("test"))
199}}
200
201tasks.test {{
202 useJUnitPlatform()
203 environment("java.library.path", "../../target/release")
204}}
205"#
206 )
207}
208
209#[allow(clippy::too_many_arguments)]
210fn render_test_file(
211 category: &str,
212 fixtures: &[&Fixture],
213 class_name: &str,
214 function_name: &str,
215 kotlin_pkg_id: &str,
216 result_var: &str,
217 args: &[crate::config::ArgMapping],
218 options_type: Option<&str>,
219 field_resolver: &FieldResolver,
220 result_is_simple: bool,
221 enum_fields: &HashSet<String>,
222 e2e_config: &E2eConfig,
223) -> String {
224 let mut out = String::new();
225 out.push_str(&hash::header(CommentStyle::DoubleSlash));
226 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
227
228 let (import_path, simple_class) = if class_name.contains('.') {
231 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
232 (class_name, simple)
233 } else {
234 ("", class_name)
235 };
236
237 let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
238 let _ = writeln!(out);
239
240 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
242
243 let needs_object_mapper_for_options = options_type.is_some()
245 && fixtures.iter().any(|f| {
246 args.iter().any(|arg| {
247 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
248 arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
249 })
250 });
251 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
253 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
254 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
255 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
256 })
257 });
258 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
260
261 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
262 let _ = writeln!(out, "import kotlin.test.assertEquals");
263 let _ = writeln!(out, "import kotlin.test.assertTrue");
264 let _ = writeln!(out, "import kotlin.test.assertFalse");
265 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
266 let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
268 if has_call_fixtures && !import_path.is_empty() {
269 let _ = writeln!(out, "import {import_path}");
270 }
271 if needs_object_mapper {
272 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
273 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
274 }
275 if let Some(opts_type) = options_type {
277 if needs_object_mapper && has_call_fixtures {
278 let opts_package = if !import_path.is_empty() {
280 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
281 format!("{pkg}.{opts_type}")
282 } else {
283 opts_type.to_string()
284 };
285 let _ = writeln!(out, "import {opts_package}");
286 }
287 }
288 if needs_object_mapper_for_handle && !import_path.is_empty() {
290 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
291 let _ = writeln!(out, "import {pkg}.CrawlConfig");
292 }
293 let _ = writeln!(out);
294
295 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
296 let _ = writeln!(out, "class {test_class_name} {{");
297
298 if needs_object_mapper {
299 let _ = writeln!(out);
300 let _ = writeln!(out, " companion object {{");
301 let _ = writeln!(
302 out,
303 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
304 );
305 let _ = writeln!(out, " }}");
306 }
307
308 for fixture in fixtures {
309 render_test_method(
310 &mut out,
311 fixture,
312 simple_class,
313 function_name,
314 result_var,
315 args,
316 options_type,
317 field_resolver,
318 result_is_simple,
319 enum_fields,
320 e2e_config,
321 );
322 let _ = writeln!(out);
323 }
324
325 let _ = writeln!(out, "}}");
326 out
327}
328
329fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
334 let method_name = fixture.id.to_upper_camel_case();
335 let description = &fixture.description;
336 let request = &http.request;
337 let expected = &http.expected_response;
338 let method = request.method.to_uppercase();
339 let fixture_id = &fixture.id;
340 let expected_status = expected.status_code;
341
342 if expected_status == 101 {
345 let _ = writeln!(out, " @Test");
346 let _ = writeln!(out, " fun test{method_name}() {{");
347 let _ = writeln!(out, " // {description}");
348 let _ = writeln!(
349 out,
350 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
351 );
352 let _ = writeln!(out, " }}");
353 return;
354 }
355
356 let _ = writeln!(out, " @Test");
357 let _ = writeln!(out, " fun test{method_name}() {{");
358 let _ = writeln!(out, " // {description}");
359 let _ = writeln!(
360 out,
361 " val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
362 );
363
364 let _ = writeln!(
367 out,
368 " val uri = java.net.URI.create(\"$baseUrl/fixtures/{fixture_id}\")"
369 );
370
371 let _ = writeln!(out, " val builder = java.net.http.HttpRequest.newBuilder(uri)");
373 let _ = writeln!(
374 out,
375 " .method(\"{method}\", {body_publisher})",
376 body_publisher = {
377 if let Some(body) = &request.body {
378 let json = serde_json::to_string(body).unwrap_or_default();
379 let escaped = escape_kotlin(&json);
380 format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
381 } else {
382 "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
383 }
384 }
385 );
386
387 const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
389
390 let content_type = request.content_type.as_deref().unwrap_or("application/json");
392 if request.body.is_some() {
393 let _ = writeln!(out, " .header(\"Content-Type\", \"{content_type}\")");
394 }
395 for (name, value) in &request.headers {
396 if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
398 continue;
399 }
400 let escaped_name = escape_kotlin(name);
401 let escaped_value = escape_kotlin(value);
402 let _ = writeln!(out, " .header(\"{escaped_name}\", \"{escaped_value}\")");
403 }
404
405 if !request.cookies.is_empty() {
407 let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
408 let cookie_header = escape_kotlin(&cookie_str.join("; "));
409 let _ = writeln!(out, " .header(\"Cookie\", \"{cookie_header}\")");
410 }
411
412 let _ = writeln!(out, " val response = java.net.http.HttpClient.newHttpClient()");
413 let _ = writeln!(
414 out,
415 " .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
416 );
417
418 let _ = writeln!(
420 out,
421 " assertEquals({expected_status}, response.statusCode(), \"status code mismatch\")"
422 );
423
424 if let Some(expected_body) = &expected.body {
426 match expected_body {
427 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
428 let json_str = serde_json::to_string(expected_body).unwrap_or_default();
429 let escaped = escape_kotlin(&json_str);
430 let _ = writeln!(out, " val bodyJson = MAPPER.readTree(response.body())");
431 let _ = writeln!(out, " val expectedJson = MAPPER.readTree(\"{escaped}\")");
432 let _ = writeln!(out, " assertEquals(expectedJson, bodyJson, \"body mismatch\")");
433 }
434 serde_json::Value::String(s) => {
435 let escaped = escape_kotlin(s);
436 let _ = writeln!(
437 out,
438 " assertEquals(\"{escaped}\", response.body().trim(), \"body mismatch\")"
439 );
440 }
441 other => {
442 let escaped = escape_kotlin(&other.to_string());
443 let _ = writeln!(
444 out,
445 " assertEquals(\"{escaped}\", response.body().trim(), \"body mismatch\")"
446 );
447 }
448 }
449 }
450
451 for (name, value) in &expected.headers {
453 if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
454 continue;
456 }
457 if name.to_lowercase() == "content-encoding" {
460 continue;
461 }
462 let escaped_name = escape_kotlin(name);
463 let escaped_value = escape_kotlin(value);
464 let _ = writeln!(
465 out,
466 " assertTrue(response.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
467 );
468 }
469
470 let _ = writeln!(out, " }}");
471}
472
473#[allow(clippy::too_many_arguments)]
474fn render_test_method(
475 out: &mut String,
476 fixture: &Fixture,
477 class_name: &str,
478 _function_name: &str,
479 _result_var: &str,
480 _args: &[crate::config::ArgMapping],
481 options_type: Option<&str>,
482 field_resolver: &FieldResolver,
483 result_is_simple: bool,
484 enum_fields: &HashSet<String>,
485 e2e_config: &E2eConfig,
486) {
487 if let Some(http) = &fixture.http {
489 render_http_test_method(out, fixture, http);
490 return;
491 }
492
493 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
495 let lang = "kotlin";
496 let call_overrides = call_config.overrides.get(lang);
497
498 if call_overrides.is_none() {
502 let method_name = fixture.id.to_upper_camel_case();
503 let description = &fixture.description;
504 let _ = writeln!(out, " @Test");
505 let _ = writeln!(out, " fun test{method_name}() {{");
506 let _ = writeln!(out, " // {description}");
507 let _ = writeln!(
508 out,
509 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"TODO: implement Kotlin e2e test for fixture '{}'\")",
510 fixture.id
511 );
512 let _ = writeln!(out, " }}");
513 return;
514 }
515 let effective_function_name = call_overrides
516 .and_then(|o| o.function.as_ref())
517 .cloned()
518 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
519 let effective_result_var = &call_config.result_var;
520 let effective_args = &call_config.args;
521 let function_name = effective_function_name.as_str();
522 let result_var = effective_result_var.as_str();
523 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
524
525 let method_name = fixture.id.to_upper_camel_case();
526 let description = &fixture.description;
527 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
528
529 let needs_deser = options_type.is_some()
531 && args
532 .iter()
533 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
534
535 let _ = writeln!(out, " @Test");
536 let _ = writeln!(out, " fun test{method_name}() {{");
537 let _ = writeln!(out, " // {description}");
538
539 if let (true, Some(opts_type)) = (needs_deser, options_type) {
541 for arg in args {
542 if arg.arg_type == "json_object" {
543 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
544 if let Some(val) = fixture.input.get(field) {
545 if !val.is_null() {
546 let normalized = super::normalize_json_keys_to_snake_case(val);
547 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
548 let var_name = &arg.name;
549 let _ = writeln!(
550 out,
551 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
552 escape_kotlin(&json_str)
553 );
554 }
555 }
556 }
557 }
558 }
559
560 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
561
562 for line in &setup_lines {
563 let _ = writeln!(out, " {line}");
564 }
565
566 if expects_error {
567 let _ = writeln!(
568 out,
569 " assertFailsWith<Exception> {{ {class_name}.{function_name}({args_str}) }}"
570 );
571 let _ = writeln!(out, " }}");
572 return;
573 }
574
575 let _ = writeln!(
576 out,
577 " val {result_var} = {class_name}.{function_name}({args_str})"
578 );
579
580 for assertion in &fixture.assertions {
581 render_assertion(
582 out,
583 assertion,
584 result_var,
585 class_name,
586 field_resolver,
587 result_is_simple,
588 enum_fields,
589 );
590 }
591
592 let _ = writeln!(out, " }}");
593}
594
595fn build_args_and_setup(
599 input: &serde_json::Value,
600 args: &[crate::config::ArgMapping],
601 class_name: &str,
602 options_type: Option<&str>,
603 fixture_id: &str,
604) -> (Vec<String>, String) {
605 if args.is_empty() {
606 return (Vec::new(), String::new());
607 }
608
609 let mut setup_lines: Vec<String> = Vec::new();
610 let mut parts: Vec<String> = Vec::new();
611
612 for arg in args {
613 if arg.arg_type == "mock_url" {
614 setup_lines.push(format!(
615 "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
616 arg.name,
617 ));
618 parts.push(arg.name.clone());
619 continue;
620 }
621
622 if arg.arg_type == "handle" {
623 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
624 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
625 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
626 if config_value.is_null()
627 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
628 {
629 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
630 } else {
631 let json_str = serde_json::to_string(config_value).unwrap_or_default();
632 let name = &arg.name;
633 setup_lines.push(format!(
634 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
635 escape_kotlin(&json_str),
636 ));
637 setup_lines.push(format!(
638 "val {} = {class_name}.{constructor_name}({name}Config)",
639 arg.name,
640 name = name,
641 ));
642 }
643 parts.push(arg.name.clone());
644 continue;
645 }
646
647 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
648 let val = input.get(field);
649 match val {
650 None | Some(serde_json::Value::Null) if arg.optional => {
651 continue;
652 }
653 None | Some(serde_json::Value::Null) => {
654 let default_val = match arg.arg_type.as_str() {
655 "string" => "\"\"".to_string(),
656 "int" | "integer" => "0".to_string(),
657 "float" | "number" => "0.0".to_string(),
658 "bool" | "boolean" => "false".to_string(),
659 _ => "null".to_string(),
660 };
661 parts.push(default_val);
662 }
663 Some(v) => {
664 if arg.arg_type == "json_object" && options_type.is_some() {
666 parts.push(arg.name.clone());
667 continue;
668 }
669 if arg.arg_type == "bytes" {
671 let val = json_to_kotlin(v);
672 parts.push(format!("{val}.toByteArray()"));
673 continue;
674 }
675 parts.push(json_to_kotlin(v));
676 }
677 }
678 }
679
680 (setup_lines, parts.join(", "))
681}
682
683fn render_assertion(
684 out: &mut String,
685 assertion: &Assertion,
686 result_var: &str,
687 _class_name: &str,
688 field_resolver: &FieldResolver,
689 result_is_simple: bool,
690 enum_fields: &HashSet<String>,
691) {
692 if let Some(f) = &assertion.field {
694 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
695 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
696 return;
697 }
698 }
699
700 let field_is_enum = assertion
702 .field
703 .as_deref()
704 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
705
706 let field_expr = if result_is_simple {
707 result_var.to_string()
708 } else {
709 match &assertion.field {
710 Some(f) if !f.is_empty() => {
711 let accessor = field_resolver.accessor(f, "kotlin", result_var);
712 let resolved = field_resolver.resolve(f);
713 if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
715 format!("{accessor}.orEmpty()")
716 } else {
717 accessor
718 }
719 }
720 _ => result_var.to_string(),
721 }
722 };
723
724 let string_expr = if field_is_enum {
726 format!("{field_expr}.getValue()")
727 } else {
728 field_expr.clone()
729 };
730
731 match assertion.assertion_type.as_str() {
732 "equals" => {
733 if let Some(expected) = &assertion.value {
734 let kotlin_val = json_to_kotlin(expected);
735 if expected.is_string() {
736 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
737 } else {
738 let _ = writeln!(out, " assertEquals({kotlin_val}, {field_expr})");
739 }
740 }
741 }
742 "contains" => {
743 if let Some(expected) = &assertion.value {
744 let kotlin_val = json_to_kotlin(expected);
745 let _ = writeln!(
746 out,
747 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
748 );
749 }
750 }
751 "contains_all" => {
752 if let Some(values) = &assertion.values {
753 for val in values {
754 let kotlin_val = json_to_kotlin(val);
755 let _ = writeln!(
756 out,
757 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
758 );
759 }
760 }
761 }
762 "not_contains" => {
763 if let Some(expected) = &assertion.value {
764 let kotlin_val = json_to_kotlin(expected);
765 let _ = writeln!(
766 out,
767 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
768 );
769 }
770 }
771 "not_empty" => {
772 let _ = writeln!(
773 out,
774 " assertFalse({field_expr}.isEmpty(), \"expected non-empty value\")"
775 );
776 }
777 "is_empty" => {
778 let _ = writeln!(
779 out,
780 " assertTrue({field_expr}.isEmpty(), \"expected empty value\")"
781 );
782 }
783 "contains_any" => {
784 if let Some(values) = &assertion.values {
785 let checks: Vec<String> = values
786 .iter()
787 .map(|v| {
788 let kotlin_val = json_to_kotlin(v);
789 format!("{string_expr}.contains({kotlin_val})")
790 })
791 .collect();
792 let joined = checks.join(" || ");
793 let _ = writeln!(
794 out,
795 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
796 );
797 }
798 }
799 "greater_than" => {
800 if let Some(val) = &assertion.value {
801 let kotlin_val = json_to_kotlin(val);
802 let _ = writeln!(
803 out,
804 " assertTrue({field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
805 );
806 }
807 }
808 "less_than" => {
809 if let Some(val) = &assertion.value {
810 let kotlin_val = json_to_kotlin(val);
811 let _ = writeln!(
812 out,
813 " assertTrue({field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
814 );
815 }
816 }
817 "greater_than_or_equal" => {
818 if let Some(val) = &assertion.value {
819 let kotlin_val = json_to_kotlin(val);
820 let _ = writeln!(
821 out,
822 " assertTrue({field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
823 );
824 }
825 }
826 "less_than_or_equal" => {
827 if let Some(val) = &assertion.value {
828 let kotlin_val = json_to_kotlin(val);
829 let _ = writeln!(
830 out,
831 " assertTrue({field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
832 );
833 }
834 }
835 "starts_with" => {
836 if let Some(expected) = &assertion.value {
837 let kotlin_val = json_to_kotlin(expected);
838 let _ = writeln!(
839 out,
840 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
841 );
842 }
843 }
844 "ends_with" => {
845 if let Some(expected) = &assertion.value {
846 let kotlin_val = json_to_kotlin(expected);
847 let _ = writeln!(
848 out,
849 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
850 );
851 }
852 }
853 "min_length" => {
854 if let Some(val) = &assertion.value {
855 if let Some(n) = val.as_u64() {
856 let _ = writeln!(
857 out,
858 " assertTrue({field_expr}.length >= {n}, \"expected length >= {n}\")"
859 );
860 }
861 }
862 }
863 "max_length" => {
864 if let Some(val) = &assertion.value {
865 if let Some(n) = val.as_u64() {
866 let _ = writeln!(
867 out,
868 " assertTrue({field_expr}.length <= {n}, \"expected length <= {n}\")"
869 );
870 }
871 }
872 }
873 "count_min" => {
874 if let Some(val) = &assertion.value {
875 if let Some(n) = val.as_u64() {
876 let _ = writeln!(
877 out,
878 " assertTrue({field_expr}.size >= {n}, \"expected at least {n} elements\")"
879 );
880 }
881 }
882 }
883 "count_equals" => {
884 if let Some(val) = &assertion.value {
885 if let Some(n) = val.as_u64() {
886 let _ = writeln!(
887 out,
888 " assertEquals({n}, {field_expr}.size, \"expected exactly {n} elements\")"
889 );
890 }
891 }
892 }
893 "is_true" => {
894 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
895 }
896 "is_false" => {
897 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
898 }
899 "matches_regex" => {
900 if let Some(expected) = &assertion.value {
901 let kotlin_val = json_to_kotlin(expected);
902 let _ = writeln!(
903 out,
904 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
905 );
906 }
907 }
908 "not_error" => {
909 }
911 "error" => {
912 }
914 "method_result" => {
915 let _ = writeln!(
917 out,
918 " // method_result assertions not yet implemented for Kotlin"
919 );
920 }
921 other => {
922 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
923 }
924 }
925}
926
927fn json_to_kotlin(value: &serde_json::Value) -> String {
929 match value {
930 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
931 serde_json::Value::Bool(b) => b.to_string(),
932 serde_json::Value::Number(n) => {
933 if n.is_f64() {
934 format!("{}d", n)
935 } else {
936 n.to_string()
937 }
938 }
939 serde_json::Value::Null => "null".to_string(),
940 serde_json::Value::Array(arr) => {
941 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
942 format!("listOf({})", items.join(", "))
943 }
944 serde_json::Value::Object(_) => {
945 let json_str = serde_json::to_string(value).unwrap_or_default();
946 format!("\"{}\"", escape_kotlin(&json_str))
947 }
948 }
949}