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