1use crate::config::E2eConfig;
7use crate::escape::{escape_kotlin, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
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;
21use super::client;
22
23pub struct KotlinE2eCodegen;
25
26impl E2eCodegen for KotlinE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 alef_config: &AlefConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let lang = self.language_name();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36 let mut files = Vec::new();
37
38 let call = &e2e_config.call;
40 let overrides = call.overrides.get(lang);
41 let _module_path = overrides
42 .and_then(|o| o.module.as_ref())
43 .cloned()
44 .unwrap_or_else(|| call.module.clone());
45 let function_name = overrides
46 .and_then(|o| o.function.as_ref())
47 .cloned()
48 .unwrap_or_else(|| call.function.clone());
49 let class_name = overrides
50 .and_then(|o| o.class.as_ref())
51 .cloned()
52 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
53 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
54 let result_var = &call.result_var;
55
56 let kotlin_pkg = e2e_config.resolve_package("kotlin");
58 let pkg_name = kotlin_pkg
59 .as_ref()
60 .and_then(|p| p.name.as_ref())
61 .cloned()
62 .unwrap_or_else(|| alef_config.crate_config.name.clone());
63
64 let _kotlin_pkg_path = kotlin_pkg
66 .as_ref()
67 .and_then(|p| p.path.as_ref())
68 .cloned()
69 .unwrap_or_else(|| "../../packages/kotlin".to_string());
70 let kotlin_version = kotlin_pkg
71 .as_ref()
72 .and_then(|p| p.version.as_ref())
73 .cloned()
74 .or_else(|| alef_config.resolved_version())
75 .unwrap_or_else(|| "0.1.0".to_string());
76 let kotlin_pkg_id = alef_config.kotlin_package();
77
78 files.push(GeneratedFile {
80 path: output_base.join("build.gradle.kts"),
81 content: render_build_gradle(&pkg_name, &kotlin_pkg_id, &kotlin_version, e2e_config.dep_mode),
82 generated_header: false,
83 });
84
85 let mut test_base = output_base.join("src").join("test").join("kotlin");
89 for segment in kotlin_pkg_id.split('.') {
90 test_base = test_base.join(segment);
91 }
92 let test_base = test_base.join("e2e");
93
94 let options_type = overrides.and_then(|o| o.options_type.clone());
96 let field_resolver = FieldResolver::new(
97 &e2e_config.fields,
98 &e2e_config.fields_optional,
99 &e2e_config.result_fields,
100 &e2e_config.fields_array,
101 );
102
103 for group in groups {
104 let active: Vec<&Fixture> = group
105 .fixtures
106 .iter()
107 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
108 .collect();
109
110 if active.is_empty() {
111 continue;
112 }
113
114 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
115 let content = render_test_file(
116 &group.category,
117 &active,
118 &class_name,
119 &function_name,
120 &kotlin_pkg_id,
121 result_var,
122 &e2e_config.call.args,
123 options_type.as_deref(),
124 &field_resolver,
125 result_is_simple,
126 &e2e_config.fields_enum,
127 e2e_config,
128 );
129 files.push(GeneratedFile {
130 path: test_base.join(class_file_name),
131 content,
132 generated_header: true,
133 });
134 }
135
136 Ok(files)
137 }
138
139 fn language_name(&self) -> &'static str {
140 "kotlin"
141 }
142}
143
144fn render_build_gradle(
149 pkg_name: &str,
150 kotlin_pkg_id: &str,
151 pkg_version: &str,
152 dep_mode: crate::config::DependencyMode,
153) -> String {
154 let dep_block = match dep_mode {
155 crate::config::DependencyMode::Registry => {
156 format!(r#" testImplementation("{pkg_name}:{pkg_version}")"#)
157 }
158 crate::config::DependencyMode::Local => {
159 format!(r#" testImplementation(files("../../packages/kotlin/build/libs/{pkg_name}-{pkg_version}.jar"))"#)
161 }
162 };
163
164 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
165 let junit = maven::JUNIT;
166 let jackson = maven::JACKSON_E2E;
167 let jvm_target = toolchain::JVM_TARGET;
168 format!(
169 r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
170
171plugins {{
172 kotlin("jvm") version "{kotlin_plugin}"
173}}
174
175group = "{kotlin_pkg_id}"
176version = "0.1.0"
177
178java {{
179 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
180 targetCompatibility = JavaVersion.VERSION_{jvm_target}
181}}
182
183kotlin {{
184 compilerOptions {{
185 jvmTarget.set(JvmTarget.JVM_{jvm_target})
186 }}
187}}
188
189repositories {{
190 mavenCentral()
191}}
192
193dependencies {{
194{dep_block}
195 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
196 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
197 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
198 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
199 testImplementation(kotlin("test"))
200}}
201
202tasks.test {{
203 useJUnitPlatform()
204 environment("java.library.path", "../../target/release")
205}}
206"#
207 )
208}
209
210#[allow(clippy::too_many_arguments)]
211fn render_test_file(
212 category: &str,
213 fixtures: &[&Fixture],
214 class_name: &str,
215 function_name: &str,
216 kotlin_pkg_id: &str,
217 result_var: &str,
218 args: &[crate::config::ArgMapping],
219 options_type: Option<&str>,
220 field_resolver: &FieldResolver,
221 result_is_simple: bool,
222 enum_fields: &HashSet<String>,
223 e2e_config: &E2eConfig,
224) -> String {
225 let mut out = String::new();
226 out.push_str(&hash::header(CommentStyle::DoubleSlash));
227 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
228
229 let (import_path, simple_class) = if class_name.contains('.') {
232 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
233 (class_name, simple)
234 } else {
235 ("", class_name)
236 };
237
238 let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
239 let _ = writeln!(out);
240
241 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
243
244 let needs_object_mapper_for_options = options_type.is_some()
246 && fixtures.iter().any(|f| {
247 args.iter().any(|arg| {
248 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
249 arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
250 })
251 });
252 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
254 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
255 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
256 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
257 })
258 });
259 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
261
262 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
263 let _ = writeln!(out, "import kotlin.test.assertEquals");
264 let _ = writeln!(out, "import kotlin.test.assertTrue");
265 let _ = writeln!(out, "import kotlin.test.assertFalse");
266 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
267 let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
269 if has_call_fixtures && !import_path.is_empty() {
270 let _ = writeln!(out, "import {import_path}");
271 }
272 if needs_object_mapper {
273 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
274 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
275 }
276 if let Some(opts_type) = options_type {
278 if needs_object_mapper && has_call_fixtures {
279 let opts_package = if !import_path.is_empty() {
281 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
282 format!("{pkg}.{opts_type}")
283 } else {
284 opts_type.to_string()
285 };
286 let _ = writeln!(out, "import {opts_package}");
287 }
288 }
289 if needs_object_mapper_for_handle && !import_path.is_empty() {
291 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
292 let _ = writeln!(out, "import {pkg}.CrawlConfig");
293 }
294 let _ = writeln!(out);
295
296 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
297 let _ = writeln!(out, "class {test_class_name} {{");
298
299 if needs_object_mapper {
300 let _ = writeln!(out);
301 let _ = writeln!(out, " companion object {{");
302 let _ = writeln!(
303 out,
304 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
305 );
306 let _ = writeln!(out, " }}");
307 }
308
309 for fixture in fixtures {
310 render_test_method(
311 &mut out,
312 fixture,
313 simple_class,
314 function_name,
315 result_var,
316 args,
317 options_type,
318 field_resolver,
319 result_is_simple,
320 enum_fields,
321 e2e_config,
322 );
323 let _ = writeln!(out);
324 }
325
326 let _ = writeln!(out, "}}");
327 out
328}
329
330struct KotlinTestClientRenderer;
337
338impl client::TestClientRenderer for KotlinTestClientRenderer {
339 fn language_name(&self) -> &'static str {
340 "kotlin"
341 }
342
343 fn sanitize_test_name(&self, id: &str) -> String {
344 sanitize_ident(id).to_upper_camel_case()
345 }
346
347 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
348 let _ = writeln!(out, " @Test");
349 let _ = writeln!(out, " fun test{fn_name}() {{");
350 let _ = writeln!(out, " // {description}");
351 if let Some(reason) = skip_reason {
352 let escaped = escape_kotlin(reason);
353 let _ = writeln!(
354 out,
355 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
356 );
357 }
358 }
359
360 fn render_test_close(&self, out: &mut String) {
361 let _ = writeln!(out, " }}");
362 }
363
364 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
365 let method = ctx.method.to_uppercase();
366 let fixture_path = ctx.path;
367
368 const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
370
371 let _ = writeln!(
372 out,
373 " val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
374 );
375 let _ = writeln!(out, " val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
376
377 let body_publisher = if let Some(body) = ctx.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 let _ = writeln!(out, " val builder = java.net.http.HttpRequest.newBuilder(uri)");
386 let _ = writeln!(out, " .method(\"{method}\", {body_publisher})");
387
388 if ctx.body.is_some() {
390 let content_type = ctx.content_type.unwrap_or("application/json");
391 let _ = writeln!(out, " .header(\"Content-Type\", \"{content_type}\")");
392 }
393
394 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
396 header_pairs.sort_by_key(|(k, _)| k.as_str());
397 for (name, value) in &header_pairs {
398 if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
399 continue;
400 }
401 let escaped_name = escape_kotlin(name);
402 let escaped_value = escape_kotlin(value);
403 let _ = writeln!(out, " .header(\"{escaped_name}\", \"{escaped_value}\")");
404 }
405
406 if !ctx.cookies.is_empty() {
408 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
409 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
410 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
411 let cookie_header = escape_kotlin(&cookie_str.join("; "));
412 let _ = writeln!(out, " .header(\"Cookie\", \"{cookie_header}\")");
413 }
414
415 let _ = writeln!(
416 out,
417 " val {} = java.net.http.HttpClient.newHttpClient()",
418 ctx.response_var
419 );
420 let _ = writeln!(
421 out,
422 " .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
423 );
424 }
425
426 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
427 let _ = writeln!(
428 out,
429 " assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
430 );
431 }
432
433 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
434 let escaped_name = escape_kotlin(name);
435 match expected {
436 "<<present>>" => {
437 let _ = writeln!(
438 out,
439 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
440 );
441 }
442 "<<absent>>" => {
443 let _ = writeln!(
444 out,
445 " assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
446 );
447 }
448 "<<uuid>>" => {
449 let _ = writeln!(
450 out,
451 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").matches(\"[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}\"), \"header {escaped_name} should be a UUID\")"
452 );
453 }
454 exact => {
455 let escaped_value = escape_kotlin(exact);
456 let _ = writeln!(
457 out,
458 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
459 );
460 }
461 }
462 }
463
464 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
465 match expected {
466 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
467 let json_str = serde_json::to_string(expected).unwrap_or_default();
468 let escaped = escape_kotlin(&json_str);
469 let _ = writeln!(out, " val bodyJson = MAPPER.readTree({response_var}.body())");
470 let _ = writeln!(out, " val expectedJson = MAPPER.readTree(\"{escaped}\")");
471 let _ = writeln!(out, " assertEquals(expectedJson, bodyJson, \"body mismatch\")");
472 }
473 serde_json::Value::String(s) => {
474 let escaped = escape_kotlin(s);
475 let _ = writeln!(
476 out,
477 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
478 );
479 }
480 other => {
481 let escaped = escape_kotlin(&other.to_string());
482 let _ = writeln!(
483 out,
484 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
485 );
486 }
487 }
488 }
489
490 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
491 if let Some(obj) = expected.as_object() {
492 let _ = writeln!(out, " val _partialTree = MAPPER.readTree({response_var}.body())");
493 for (key, val) in obj {
494 let escaped_key = escape_kotlin(key);
495 match val {
496 serde_json::Value::String(s) => {
497 let escaped_val = escape_kotlin(s);
498 let _ = writeln!(
499 out,
500 " assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
501 );
502 }
503 serde_json::Value::Bool(b) => {
504 let _ = writeln!(
505 out,
506 " assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
507 );
508 }
509 serde_json::Value::Number(n) => {
510 let _ = writeln!(
511 out,
512 " assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
513 );
514 }
515 other => {
516 let json_str = serde_json::to_string(other).unwrap_or_default();
517 let escaped_val = escape_kotlin(&json_str);
518 let _ = writeln!(
519 out,
520 " assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
521 );
522 }
523 }
524 }
525 }
526 }
527
528 fn render_assert_validation_errors(
529 &self,
530 out: &mut String,
531 response_var: &str,
532 errors: &[ValidationErrorExpectation],
533 ) {
534 let _ = writeln!(out, " val _veTree = MAPPER.readTree({response_var}.body())");
535 let _ = writeln!(out, " val _veErrors = _veTree.path(\"errors\")");
536 for ve in errors {
537 let escaped_msg = escape_kotlin(&ve.msg);
538 let _ = writeln!(
539 out,
540 " assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
541 );
542 }
543 }
544}
545
546fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
551 if http.expected_response.status_code == 101 {
553 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
554 let description = &fixture.description;
555 let _ = writeln!(out, " @Test");
556 let _ = writeln!(out, " fun test{method_name}() {{");
557 let _ = writeln!(out, " // {description}");
558 let _ = writeln!(
559 out,
560 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
561 );
562 let _ = writeln!(out, " }}");
563 return;
564 }
565
566 client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
567}
568
569#[allow(clippy::too_many_arguments)]
570fn render_test_method(
571 out: &mut String,
572 fixture: &Fixture,
573 class_name: &str,
574 _function_name: &str,
575 _result_var: &str,
576 _args: &[crate::config::ArgMapping],
577 options_type: Option<&str>,
578 field_resolver: &FieldResolver,
579 result_is_simple: bool,
580 enum_fields: &HashSet<String>,
581 e2e_config: &E2eConfig,
582) {
583 if let Some(http) = &fixture.http {
585 render_http_test_method(out, fixture, http);
586 return;
587 }
588
589 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
591 let lang = "kotlin";
592 let call_overrides = call_config.overrides.get(lang);
593
594 if call_overrides.is_none() {
598 let method_name = fixture.id.to_upper_camel_case();
599 let description = &fixture.description;
600 let _ = writeln!(out, " @Test");
601 let _ = writeln!(out, " fun test{method_name}() {{");
602 let _ = writeln!(out, " // {description}");
603 let _ = writeln!(
604 out,
605 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"TODO: implement Kotlin e2e test for fixture '{}'\")",
606 fixture.id
607 );
608 let _ = writeln!(out, " }}");
609 return;
610 }
611 let effective_function_name = call_overrides
612 .and_then(|o| o.function.as_ref())
613 .cloned()
614 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
615 let effective_result_var = &call_config.result_var;
616 let effective_args = &call_config.args;
617 let function_name = effective_function_name.as_str();
618 let result_var = effective_result_var.as_str();
619 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
620
621 let method_name = fixture.id.to_upper_camel_case();
622 let description = &fixture.description;
623 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
624
625 let needs_deser = options_type.is_some()
627 && args
628 .iter()
629 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
630
631 let _ = writeln!(out, " @Test");
632 let _ = writeln!(out, " fun test{method_name}() {{");
633 let _ = writeln!(out, " // {description}");
634
635 if let (true, Some(opts_type)) = (needs_deser, options_type) {
637 for arg in args {
638 if arg.arg_type == "json_object" {
639 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
640 if let Some(val) = fixture.input.get(field) {
641 if !val.is_null() {
642 let normalized = super::normalize_json_keys_to_snake_case(val);
643 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
644 let var_name = &arg.name;
645 let _ = writeln!(
646 out,
647 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
648 escape_kotlin(&json_str)
649 );
650 }
651 }
652 }
653 }
654 }
655
656 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
657
658 for line in &setup_lines {
659 let _ = writeln!(out, " {line}");
660 }
661
662 if expects_error {
663 let _ = writeln!(
664 out,
665 " assertFailsWith<Exception> {{ {class_name}.{function_name}({args_str}) }}"
666 );
667 let _ = writeln!(out, " }}");
668 return;
669 }
670
671 let _ = writeln!(
672 out,
673 " val {result_var} = {class_name}.{function_name}({args_str})"
674 );
675
676 for assertion in &fixture.assertions {
677 render_assertion(
678 out,
679 assertion,
680 result_var,
681 class_name,
682 field_resolver,
683 result_is_simple,
684 enum_fields,
685 );
686 }
687
688 let _ = writeln!(out, " }}");
689}
690
691fn build_args_and_setup(
695 input: &serde_json::Value,
696 args: &[crate::config::ArgMapping],
697 class_name: &str,
698 options_type: Option<&str>,
699 fixture_id: &str,
700) -> (Vec<String>, String) {
701 if args.is_empty() {
702 return (Vec::new(), String::new());
703 }
704
705 let mut setup_lines: Vec<String> = Vec::new();
706 let mut parts: Vec<String> = Vec::new();
707
708 for arg in args {
709 if arg.arg_type == "mock_url" {
710 setup_lines.push(format!(
711 "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
712 arg.name,
713 ));
714 parts.push(arg.name.clone());
715 continue;
716 }
717
718 if arg.arg_type == "handle" {
719 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
720 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
721 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
722 if config_value.is_null()
723 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
724 {
725 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
726 } else {
727 let json_str = serde_json::to_string(config_value).unwrap_or_default();
728 let name = &arg.name;
729 setup_lines.push(format!(
730 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
731 escape_kotlin(&json_str),
732 ));
733 setup_lines.push(format!(
734 "val {} = {class_name}.{constructor_name}({name}Config)",
735 arg.name,
736 name = name,
737 ));
738 }
739 parts.push(arg.name.clone());
740 continue;
741 }
742
743 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
744 let val = input.get(field);
745 match val {
746 None | Some(serde_json::Value::Null) if arg.optional => {
747 continue;
748 }
749 None | Some(serde_json::Value::Null) => {
750 let default_val = match arg.arg_type.as_str() {
751 "string" => "\"\"".to_string(),
752 "int" | "integer" => "0".to_string(),
753 "float" | "number" => "0.0".to_string(),
754 "bool" | "boolean" => "false".to_string(),
755 _ => "null".to_string(),
756 };
757 parts.push(default_val);
758 }
759 Some(v) => {
760 if arg.arg_type == "json_object" && options_type.is_some() {
762 parts.push(arg.name.clone());
763 continue;
764 }
765 if arg.arg_type == "bytes" {
767 let val = json_to_kotlin(v);
768 parts.push(format!("{val}.toByteArray()"));
769 continue;
770 }
771 parts.push(json_to_kotlin(v));
772 }
773 }
774 }
775
776 (setup_lines, parts.join(", "))
777}
778
779fn render_assertion(
780 out: &mut String,
781 assertion: &Assertion,
782 result_var: &str,
783 _class_name: &str,
784 field_resolver: &FieldResolver,
785 result_is_simple: bool,
786 enum_fields: &HashSet<String>,
787) {
788 if let Some(f) = &assertion.field {
790 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
791 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
792 return;
793 }
794 }
795
796 let field_is_enum = assertion
798 .field
799 .as_deref()
800 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
801
802 let field_expr = if result_is_simple {
803 result_var.to_string()
804 } else {
805 match &assertion.field {
806 Some(f) if !f.is_empty() => {
807 let accessor = field_resolver.accessor(f, "kotlin", result_var);
808 let resolved = field_resolver.resolve(f);
809 if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
811 format!("{accessor}.orEmpty()")
812 } else {
813 accessor
814 }
815 }
816 _ => result_var.to_string(),
817 }
818 };
819
820 let string_expr = if field_is_enum {
822 format!("{field_expr}.getValue()")
823 } else {
824 field_expr.clone()
825 };
826
827 match assertion.assertion_type.as_str() {
828 "equals" => {
829 if let Some(expected) = &assertion.value {
830 let kotlin_val = json_to_kotlin(expected);
831 if expected.is_string() {
832 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
833 } else {
834 let _ = writeln!(out, " assertEquals({kotlin_val}, {field_expr})");
835 }
836 }
837 }
838 "contains" => {
839 if let Some(expected) = &assertion.value {
840 let kotlin_val = json_to_kotlin(expected);
841 let _ = writeln!(
842 out,
843 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
844 );
845 }
846 }
847 "contains_all" => {
848 if let Some(values) = &assertion.values {
849 for val in values {
850 let kotlin_val = json_to_kotlin(val);
851 let _ = writeln!(
852 out,
853 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
854 );
855 }
856 }
857 }
858 "not_contains" => {
859 if let Some(expected) = &assertion.value {
860 let kotlin_val = json_to_kotlin(expected);
861 let _ = writeln!(
862 out,
863 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
864 );
865 }
866 }
867 "not_empty" => {
868 let _ = writeln!(
869 out,
870 " assertFalse({field_expr}.isEmpty(), \"expected non-empty value\")"
871 );
872 }
873 "is_empty" => {
874 let _ = writeln!(
875 out,
876 " assertTrue({field_expr}.isEmpty(), \"expected empty value\")"
877 );
878 }
879 "contains_any" => {
880 if let Some(values) = &assertion.values {
881 let checks: Vec<String> = values
882 .iter()
883 .map(|v| {
884 let kotlin_val = json_to_kotlin(v);
885 format!("{string_expr}.contains({kotlin_val})")
886 })
887 .collect();
888 let joined = checks.join(" || ");
889 let _ = writeln!(
890 out,
891 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
892 );
893 }
894 }
895 "greater_than" => {
896 if let Some(val) = &assertion.value {
897 let kotlin_val = json_to_kotlin(val);
898 let _ = writeln!(
899 out,
900 " assertTrue({field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
901 );
902 }
903 }
904 "less_than" => {
905 if let Some(val) = &assertion.value {
906 let kotlin_val = json_to_kotlin(val);
907 let _ = writeln!(
908 out,
909 " assertTrue({field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
910 );
911 }
912 }
913 "greater_than_or_equal" => {
914 if let Some(val) = &assertion.value {
915 let kotlin_val = json_to_kotlin(val);
916 let _ = writeln!(
917 out,
918 " assertTrue({field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
919 );
920 }
921 }
922 "less_than_or_equal" => {
923 if let Some(val) = &assertion.value {
924 let kotlin_val = json_to_kotlin(val);
925 let _ = writeln!(
926 out,
927 " assertTrue({field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
928 );
929 }
930 }
931 "starts_with" => {
932 if let Some(expected) = &assertion.value {
933 let kotlin_val = json_to_kotlin(expected);
934 let _ = writeln!(
935 out,
936 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
937 );
938 }
939 }
940 "ends_with" => {
941 if let Some(expected) = &assertion.value {
942 let kotlin_val = json_to_kotlin(expected);
943 let _ = writeln!(
944 out,
945 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
946 );
947 }
948 }
949 "min_length" => {
950 if let Some(val) = &assertion.value {
951 if let Some(n) = val.as_u64() {
952 let _ = writeln!(
953 out,
954 " assertTrue({field_expr}.length >= {n}, \"expected length >= {n}\")"
955 );
956 }
957 }
958 }
959 "max_length" => {
960 if let Some(val) = &assertion.value {
961 if let Some(n) = val.as_u64() {
962 let _ = writeln!(
963 out,
964 " assertTrue({field_expr}.length <= {n}, \"expected length <= {n}\")"
965 );
966 }
967 }
968 }
969 "count_min" => {
970 if let Some(val) = &assertion.value {
971 if let Some(n) = val.as_u64() {
972 let _ = writeln!(
973 out,
974 " assertTrue({field_expr}.size >= {n}, \"expected at least {n} elements\")"
975 );
976 }
977 }
978 }
979 "count_equals" => {
980 if let Some(val) = &assertion.value {
981 if let Some(n) = val.as_u64() {
982 let _ = writeln!(
983 out,
984 " assertEquals({n}, {field_expr}.size, \"expected exactly {n} elements\")"
985 );
986 }
987 }
988 }
989 "is_true" => {
990 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
991 }
992 "is_false" => {
993 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
994 }
995 "matches_regex" => {
996 if let Some(expected) = &assertion.value {
997 let kotlin_val = json_to_kotlin(expected);
998 let _ = writeln!(
999 out,
1000 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1001 );
1002 }
1003 }
1004 "not_error" => {
1005 }
1007 "error" => {
1008 }
1010 "method_result" => {
1011 let _ = writeln!(
1013 out,
1014 " // method_result assertions not yet implemented for Kotlin"
1015 );
1016 }
1017 other => {
1018 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1019 }
1020 }
1021}
1022
1023fn json_to_kotlin(value: &serde_json::Value) -> String {
1025 match value {
1026 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1027 serde_json::Value::Bool(b) => b.to_string(),
1028 serde_json::Value::Number(n) => {
1029 if n.is_f64() {
1030 format!("{}d", n)
1031 } else {
1032 n.to_string()
1033 }
1034 }
1035 serde_json::Value::Null => "null".to_string(),
1036 serde_json::Value::Array(arr) => {
1037 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1038 format!("listOf({})", items.join(", "))
1039 }
1040 serde_json::Value::Object(_) => {
1041 let json_str = serde_json::to_string(value).unwrap_or_default();
1042 format!("\"{}\"", escape_kotlin(&json_str))
1043 }
1044 }
1045}