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