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