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 let needs_mock_server = groups
85 .iter()
86 .flat_map(|g| g.fixtures.iter())
87 .any(|f| f.needs_mock_server());
88
89 files.push(GeneratedFile {
91 path: output_base.join("build.gradle.kts"),
92 content: render_build_gradle(
93 &pkg_name,
94 &kotlin_pkg_id,
95 &kotlin_version,
96 e2e_config.dep_mode,
97 needs_mock_server,
98 ),
99 generated_header: false,
100 });
101
102 let mut test_base = output_base.join("src").join("test").join("kotlin");
106 for segment in kotlin_pkg_id.split('.') {
107 test_base = test_base.join(segment);
108 }
109 let test_base = test_base.join("e2e");
110
111 if needs_mock_server {
112 files.push(GeneratedFile {
113 path: test_base.join("MockServerListener.kt"),
114 content: render_mock_server_listener_kt(&kotlin_pkg_id),
115 generated_header: true,
116 });
117 files.push(GeneratedFile {
118 path: output_base
119 .join("src")
120 .join("test")
121 .join("resources")
122 .join("META-INF")
123 .join("services")
124 .join("org.junit.platform.launcher.LauncherSessionListener"),
125 content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
126 generated_header: false,
127 });
128 }
129
130 let options_type = overrides.and_then(|o| o.options_type.clone());
132 let field_resolver = FieldResolver::new(
133 &e2e_config.fields,
134 &e2e_config.fields_optional,
135 &e2e_config.result_fields,
136 &e2e_config.fields_array,
137 &HashSet::new(),
138 );
139
140 for group in groups {
141 let active: Vec<&Fixture> = group
142 .fixtures
143 .iter()
144 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
145 .collect();
146
147 if active.is_empty() {
148 continue;
149 }
150
151 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
152 let content = render_test_file(
153 &group.category,
154 &active,
155 &class_name,
156 &function_name,
157 &kotlin_pkg_id,
158 result_var,
159 &e2e_config.call.args,
160 options_type.as_deref(),
161 &field_resolver,
162 result_is_simple,
163 &e2e_config.fields_enum,
164 e2e_config,
165 );
166 files.push(GeneratedFile {
167 path: test_base.join(class_file_name),
168 content,
169 generated_header: true,
170 });
171 }
172
173 Ok(files)
174 }
175
176 fn language_name(&self) -> &'static str {
177 "kotlin"
178 }
179}
180
181fn render_build_gradle(
186 pkg_name: &str,
187 kotlin_pkg_id: &str,
188 pkg_version: &str,
189 dep_mode: crate::config::DependencyMode,
190 needs_mock_server: bool,
191) -> String {
192 let dep_block = match dep_mode {
193 crate::config::DependencyMode::Registry => {
194 format!(r#" testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
196 }
197 crate::config::DependencyMode::Local => {
198 let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name);
205 let jna = maven::JNA;
206 let jackson = maven::JACKSON_E2E;
207 let jspecify = maven::JSPECIFY;
208 let coroutines = maven::KOTLINX_COROUTINES_CORE;
209 format!(
210 r#" testImplementation(files("../../packages/kotlin/build/libs/{jar_name}-{pkg_version}.jar"))
211 testImplementation("net.java.dev.jna:jna:{jna}")
212 testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
213 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
214 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
215 testImplementation("org.jspecify:jspecify:{jspecify}")
216 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")"#
217 )
218 }
219 };
220
221 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
222 let junit = maven::JUNIT;
223 let jackson = maven::JACKSON_E2E;
224 let jvm_target = toolchain::JVM_TARGET;
225 let launcher_dep = if needs_mock_server {
226 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
227 } else {
228 String::new()
229 };
230 format!(
231 r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
232
233plugins {{
234 kotlin("jvm") version "{kotlin_plugin}"
235}}
236
237group = "{kotlin_pkg_id}"
238version = "0.1.0"
239
240java {{
241 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
242 targetCompatibility = JavaVersion.VERSION_{jvm_target}
243}}
244
245kotlin {{
246 compilerOptions {{
247 jvmTarget.set(JvmTarget.JVM_{jvm_target})
248 }}
249}}
250
251repositories {{
252 mavenCentral()
253}}
254
255dependencies {{
256{dep_block}
257 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
258 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
259{launcher_dep}
260 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
261 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
262 testImplementation(kotlin("test"))
263}}
264
265tasks.test {{
266 useJUnitPlatform()
267 val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
268 systemProperty("java.library.path", libPath)
269 systemProperty("jna.library.path", libPath)
270}}
271"#
272 )
273}
274
275fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
284 let header = hash::header(CommentStyle::DoubleSlash);
285 format!(
286 r#"{header}package {kotlin_pkg_id}.e2e
287
288import java.io.BufferedReader
289import java.io.IOException
290import java.io.InputStreamReader
291import java.nio.charset.StandardCharsets
292import java.nio.file.Path
293import java.nio.file.Paths
294import java.util.regex.Pattern
295import org.junit.platform.launcher.LauncherSession
296import org.junit.platform.launcher.LauncherSessionListener
297
298/**
299 * Spawns the mock-server binary once per JUnit launcher session and
300 * exposes its URL as the `mockServerUrl` system property. Generated
301 * test bodies read the property (with `MOCK_SERVER_URL` env-var
302 * fallback) so tests can run via plain `./gradlew test` without any
303 * external mock-server orchestration. Mirrors the Ruby spec_helper /
304 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
305 * skipping the spawn entirely.
306 */
307class MockServerListener : LauncherSessionListener {{
308 private var mockServer: Process? = null
309
310 override fun launcherSessionOpened(session: LauncherSession) {{
311 val preset = System.getenv("MOCK_SERVER_URL")
312 if (!preset.isNullOrEmpty()) {{
313 System.setProperty("mockServerUrl", preset)
314 return
315 }}
316 val repoRoot = locateRepoRoot()
317 ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
318 val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
319 val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
320 val fixturesDir = repoRoot.resolve("fixtures").toFile()
321 check(bin.exists()) {{
322 "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
323 }}
324 val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
325 .redirectErrorStream(false)
326 val server = try {{
327 pb.start()
328 }} catch (e: IOException) {{
329 throw IllegalStateException("MockServerListener: failed to start mock-server", e)
330 }}
331 mockServer = server
332 // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
333 // Cap the loop so a misbehaving mock-server cannot block indefinitely.
334 val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
335 var url: String? = null
336 try {{
337 for (i in 0 until 16) {{
338 val line = stdout.readLine() ?: break
339 when {{
340 line.startsWith("MOCK_SERVER_URL=") -> {{
341 url = line.removePrefix("MOCK_SERVER_URL=").trim()
342 }}
343 line.startsWith("MOCK_SERVERS=") -> {{
344 val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
345 System.setProperty("mockServers", jsonVal)
346 // Parse JSON map of fixture_id -> url and expose as system properties.
347 val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
348 val matcher = p.matcher(jsonVal)
349 while (matcher.find()) {{
350 System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
351 }}
352 break
353 }}
354 url != null -> break
355 }}
356 }}
357 }} catch (e: IOException) {{
358 server.destroyForcibly()
359 throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
360 }}
361 if (url.isNullOrEmpty()) {{
362 server.destroyForcibly()
363 error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
364 }}
365 // TCP-readiness probe: ensure axum::serve is accepting before tests start.
366 // The mock-server binds the TcpListener synchronously then prints the URL
367 // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
368 // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
369 val healthUri = java.net.URI.create(url)
370 val host = healthUri.host
371 val port = healthUri.port
372 val deadline = System.nanoTime() + 5_000_000_000L
373 while (System.nanoTime() < deadline) {{
374 try {{
375 java.net.Socket().use {{ s ->
376 s.connect(java.net.InetSocketAddress(host, port), 100)
377 break
378 }}
379 }} catch (_: java.io.IOException) {{
380 try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
381 }}
382 }}
383 System.setProperty("mockServerUrl", url)
384 // Drain remaining stdout/stderr in daemon threads so a full pipe
385 // does not block the child.
386 Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
387 Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
388 }}
389
390 override fun launcherSessionClosed(session: LauncherSession) {{
391 val server = mockServer ?: return
392 try {{ server.outputStream.close() }} catch (_: IOException) {{}}
393 try {{
394 if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
395 server.destroyForcibly()
396 }}
397 }} catch (ie: InterruptedException) {{
398 Thread.currentThread().interrupt()
399 server.destroyForcibly()
400 }}
401 }}
402
403 companion object {{
404 private fun locateRepoRoot(): Path? {{
405 var dir: Path? = Paths.get("").toAbsolutePath()
406 while (dir != null) {{
407 if (dir.resolve("fixtures").toFile().isDirectory
408 && dir.resolve("e2e").toFile().isDirectory) {{
409 return dir
410 }}
411 dir = dir.parent
412 }}
413 return null
414 }}
415
416 private fun drain(reader: BufferedReader) {{
417 try {{
418 val buf = CharArray(1024)
419 while (reader.read(buf) >= 0) {{ /* drain */ }}
420 }} catch (_: IOException) {{}}
421 }}
422 }}
423}}
424"#
425 )
426}
427
428#[allow(clippy::too_many_arguments)]
429fn render_test_file(
430 category: &str,
431 fixtures: &[&Fixture],
432 class_name: &str,
433 function_name: &str,
434 kotlin_pkg_id: &str,
435 result_var: &str,
436 args: &[crate::config::ArgMapping],
437 options_type: Option<&str>,
438 field_resolver: &FieldResolver,
439 result_is_simple: bool,
440 enum_fields: &HashSet<String>,
441 e2e_config: &E2eConfig,
442) -> String {
443 let mut out = String::new();
444 out.push_str(&hash::header(CommentStyle::DoubleSlash));
445 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
446
447 let (import_path, simple_class) = if class_name.contains('.') {
450 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
451 (class_name, simple)
452 } else {
453 ("", class_name)
454 };
455
456 let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
457 let _ = writeln!(out);
458
459 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
461
462 let mut per_fixture_options_types: HashSet<String> = HashSet::new();
466 for f in fixtures.iter() {
467 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
468 let call_overrides = cc.overrides.get("kotlin");
469 let effective_opts: Option<String> = call_overrides
470 .and_then(|o| o.options_type.clone())
471 .or_else(|| options_type.map(|s| s.to_string()))
472 .or_else(|| {
473 for cand in ["csharp", "c", "go", "php", "python"] {
474 if let Some(o) = cc.overrides.get(cand) {
475 if let Some(t) = &o.options_type {
476 return Some(t.clone());
477 }
478 }
479 }
480 None
481 });
482 if let Some(opts) = effective_opts {
483 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
486 let needs_opts_type = fixture_args.iter().any(|arg| {
491 if arg.arg_type != "json_object" {
492 return false;
493 }
494 let v = super::resolve_field(&f.input, &arg.field);
495 !v.is_null() || arg.optional
496 });
497 if needs_opts_type {
498 per_fixture_options_types.insert(opts.to_string());
499 }
500 }
501 }
502 let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
503 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
505 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
506 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
507 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
508 })
509 });
510 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
512
513 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
514 let _ = writeln!(out, "import kotlin.test.assertEquals");
515 let _ = writeln!(out, "import kotlin.test.assertTrue");
516 let _ = writeln!(out, "import kotlin.test.assertFalse");
517 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
518 let binding_pkg_for_imports: String = if !import_path.is_empty() {
524 import_path
525 .rsplit_once('.')
526 .map(|(p, _)| p.to_string())
527 .unwrap_or_else(|| kotlin_pkg_id.to_string())
528 } else {
529 kotlin_pkg_id.to_string()
530 };
531 let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
533 if has_call_fixtures {
534 if !import_path.is_empty() {
535 let _ = writeln!(out, "import {import_path}");
536 } else if !class_name.is_empty() {
537 let _ = writeln!(out, "import {binding_pkg_for_imports}.{class_name}");
538 }
539 }
540 if needs_object_mapper {
541 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
542 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
543 }
544 if has_call_fixtures {
548 let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
549 sorted_opts.sort();
550 for opts_type in sorted_opts {
551 let _ = writeln!(out, "import {binding_pkg_for_imports}.{opts_type}");
552 }
553 }
554 if needs_object_mapper_for_handle {
556 let _ = writeln!(out, "import {binding_pkg_for_imports}.CrawlConfig");
557 }
558 let mut batch_elem_imports: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
561 for f in fixtures.iter() {
562 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
563 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
564 for arg in fixture_args.iter() {
565 if arg.arg_type != "json_object" {
566 continue;
567 }
568 let v = super::resolve_field(&f.input, &arg.field);
569 if !v.is_array() {
570 continue;
571 }
572 if let Some(elem) = &arg.element_type {
573 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
574 batch_elem_imports.insert(elem.clone());
575 }
576 }
577 }
578 }
579 for elem in &batch_elem_imports {
580 let _ = writeln!(out, "import {binding_pkg_for_imports}.{elem}");
581 }
582 let _ = writeln!(out);
583
584 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
585 let _ = writeln!(out, "class {test_class_name} {{");
586
587 if needs_object_mapper {
588 let _ = writeln!(out);
589 let _ = writeln!(out, " companion object {{");
590 let _ = writeln!(
591 out,
592 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
593 );
594 let _ = writeln!(out, " }}");
595 }
596
597 for fixture in fixtures {
598 render_test_method(
599 &mut out,
600 fixture,
601 simple_class,
602 function_name,
603 result_var,
604 args,
605 options_type,
606 field_resolver,
607 result_is_simple,
608 enum_fields,
609 e2e_config,
610 );
611 let _ = writeln!(out);
612 }
613
614 let _ = writeln!(out, "}}");
615 out
616}
617
618struct KotlinTestClientRenderer;
625
626impl client::TestClientRenderer for KotlinTestClientRenderer {
627 fn language_name(&self) -> &'static str {
628 "kotlin"
629 }
630
631 fn sanitize_test_name(&self, id: &str) -> String {
632 sanitize_ident(id).to_upper_camel_case()
633 }
634
635 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
636 let _ = writeln!(out, " @Test");
637 let _ = writeln!(out, " fun test{fn_name}() {{");
638 let _ = writeln!(out, " // {description}");
639 if let Some(reason) = skip_reason {
640 let escaped = escape_kotlin(reason);
641 let _ = writeln!(
642 out,
643 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
644 );
645 }
646 }
647
648 fn render_test_close(&self, out: &mut String) {
649 let _ = writeln!(out, " }}");
650 }
651
652 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
653 let method = ctx.method.to_uppercase();
654 let fixture_path = ctx.path;
655
656 const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
658
659 let _ = writeln!(
660 out,
661 " val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
662 );
663 let _ = writeln!(out, " val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
664
665 let body_publisher = if let Some(body) = ctx.body {
666 let json = serde_json::to_string(body).unwrap_or_default();
667 let escaped = escape_kotlin(&json);
668 format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
669 } else {
670 "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
671 };
672
673 let _ = writeln!(out, " val builder = java.net.http.HttpRequest.newBuilder(uri)");
674 let _ = writeln!(out, " .method(\"{method}\", {body_publisher})");
675
676 if ctx.body.is_some() {
678 let content_type = ctx.content_type.unwrap_or("application/json");
679 let _ = writeln!(out, " .header(\"Content-Type\", \"{content_type}\")");
680 }
681
682 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
684 header_pairs.sort_by_key(|(k, _)| k.as_str());
685 for (name, value) in &header_pairs {
686 if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
687 continue;
688 }
689 let escaped_name = escape_kotlin(name);
690 let escaped_value = escape_kotlin(value);
691 let _ = writeln!(out, " .header(\"{escaped_name}\", \"{escaped_value}\")");
692 }
693
694 if !ctx.cookies.is_empty() {
696 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
697 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
698 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
699 let cookie_header = escape_kotlin(&cookie_str.join("; "));
700 let _ = writeln!(out, " .header(\"Cookie\", \"{cookie_header}\")");
701 }
702
703 let _ = writeln!(
704 out,
705 " val {} = java.net.http.HttpClient.newHttpClient()",
706 ctx.response_var
707 );
708 let _ = writeln!(
709 out,
710 " .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
711 );
712 }
713
714 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
715 let _ = writeln!(
716 out,
717 " assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
718 );
719 }
720
721 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
722 let escaped_name = escape_kotlin(name);
723 match expected {
724 "<<present>>" => {
725 let _ = writeln!(
726 out,
727 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
728 );
729 }
730 "<<absent>>" => {
731 let _ = writeln!(
732 out,
733 " assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
734 );
735 }
736 "<<uuid>>" => {
737 let _ = writeln!(
738 out,
739 " 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\")"
740 );
741 }
742 exact => {
743 let escaped_value = escape_kotlin(exact);
744 let _ = writeln!(
745 out,
746 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
747 );
748 }
749 }
750 }
751
752 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
753 match expected {
754 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
755 let json_str = serde_json::to_string(expected).unwrap_or_default();
756 let escaped = escape_kotlin(&json_str);
757 let _ = writeln!(out, " val bodyJson = MAPPER.readTree({response_var}.body())");
758 let _ = writeln!(out, " val expectedJson = MAPPER.readTree(\"{escaped}\")");
759 let _ = writeln!(out, " assertEquals(expectedJson, bodyJson, \"body mismatch\")");
760 }
761 serde_json::Value::String(s) => {
762 let escaped = escape_kotlin(s);
763 let _ = writeln!(
764 out,
765 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
766 );
767 }
768 other => {
769 let escaped = escape_kotlin(&other.to_string());
770 let _ = writeln!(
771 out,
772 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
773 );
774 }
775 }
776 }
777
778 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
779 if let Some(obj) = expected.as_object() {
780 let _ = writeln!(out, " val _partialTree = MAPPER.readTree({response_var}.body())");
781 for (key, val) in obj {
782 let escaped_key = escape_kotlin(key);
783 match val {
784 serde_json::Value::String(s) => {
785 let escaped_val = escape_kotlin(s);
786 let _ = writeln!(
787 out,
788 " assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
789 );
790 }
791 serde_json::Value::Bool(b) => {
792 let _ = writeln!(
793 out,
794 " assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
795 );
796 }
797 serde_json::Value::Number(n) => {
798 let _ = writeln!(
799 out,
800 " assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
801 );
802 }
803 other => {
804 let json_str = serde_json::to_string(other).unwrap_or_default();
805 let escaped_val = escape_kotlin(&json_str);
806 let _ = writeln!(
807 out,
808 " assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
809 );
810 }
811 }
812 }
813 }
814 }
815
816 fn render_assert_validation_errors(
817 &self,
818 out: &mut String,
819 response_var: &str,
820 errors: &[ValidationErrorExpectation],
821 ) {
822 let _ = writeln!(out, " val _veTree = MAPPER.readTree({response_var}.body())");
823 let _ = writeln!(out, " val _veErrors = _veTree.path(\"errors\")");
824 for ve in errors {
825 let escaped_msg = escape_kotlin(&ve.msg);
826 let _ = writeln!(
827 out,
828 " assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
829 );
830 }
831 }
832}
833
834fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
839 if http.expected_response.status_code == 101 {
841 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
842 let description = &fixture.description;
843 let _ = writeln!(out, " @Test");
844 let _ = writeln!(out, " fun test{method_name}() {{");
845 let _ = writeln!(out, " // {description}");
846 let _ = writeln!(
847 out,
848 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
849 );
850 let _ = writeln!(out, " }}");
851 return;
852 }
853
854 client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
855}
856
857#[allow(clippy::too_many_arguments)]
858fn render_test_method(
859 out: &mut String,
860 fixture: &Fixture,
861 class_name: &str,
862 _function_name: &str,
863 _result_var: &str,
864 _args: &[crate::config::ArgMapping],
865 options_type: Option<&str>,
866 field_resolver: &FieldResolver,
867 result_is_simple: bool,
868 enum_fields: &HashSet<String>,
869 e2e_config: &E2eConfig,
870) {
871 if let Some(http) = &fixture.http {
873 render_http_test_method(out, fixture, http);
874 return;
875 }
876
877 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
879 let lang = "kotlin";
880 let call_overrides = call_config.overrides.get(lang);
881
882 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
886 e2e_config
887 .call
888 .overrides
889 .get(lang)
890 .and_then(|o| o.client_factory.as_deref())
891 });
892
893 let effective_function_name = call_overrides
894 .and_then(|o| o.function.as_ref())
895 .cloned()
896 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
897 let effective_result_var = &call_config.result_var;
898 let effective_args = &call_config.args;
899 let function_name = effective_function_name.as_str();
900 let result_var = effective_result_var.as_str();
901 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
902 let effective_options_type: Option<String> = call_overrides
907 .and_then(|o| o.options_type.clone())
908 .or_else(|| options_type.map(|s| s.to_string()))
909 .or_else(|| {
910 for cand in ["csharp", "c", "go", "php", "python"] {
911 if let Some(o) = call_config.overrides.get(cand) {
912 if let Some(t) = &o.options_type {
913 return Some(t.clone());
914 }
915 }
916 }
917 None
918 });
919 let options_type = effective_options_type.as_deref();
920
921 let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple)
926 || call_config.result_is_simple
927 || result_is_simple
928 || ["java", "csharp", "go"]
929 .iter()
930 .any(|cand| call_config.overrides.get(*cand).is_some_and(|o| o.result_is_simple));
931 let result_is_simple = effective_result_is_simple;
932
933 let method_name = fixture.id.to_upper_camel_case();
934 let description = &fixture.description;
935 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
936
937 let is_streaming = fixture.is_streaming_mock()
942 || fixture.assertions.iter().any(|a| {
943 a.field
944 .as_deref()
945 .is_some_and(|f| !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f))
946 });
947 let collect_snippet = if is_streaming && !expects_error {
948 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("kotlin", result_var, "chunks")
949 .unwrap_or_default()
950 } else {
951 String::new()
952 };
953
954 let needs_deser = options_type.is_some()
958 && args
959 .iter()
960 .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&fixture.input, &arg.field).is_null());
961
962 let _ = writeln!(out, " @Test");
963 let _ = writeln!(out, " fun test{method_name}() {{");
964 let _ = writeln!(out, " // {description}");
965
966 if needs_deser {
972 for arg in args {
973 if arg.arg_type != "json_object" {
974 continue;
975 }
976 let val = super::resolve_field(&fixture.input, &arg.field);
977 if val.is_null() {
978 continue;
979 }
980 if val.is_array() && arg.element_type.is_some() {
983 continue;
984 }
985 let Some(opts_type) = options_type else { continue };
986 let normalized = super::transform_json_keys_for_language(val, "snake_case");
987 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
988 let var_name = &arg.name;
989 let _ = writeln!(
990 out,
991 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
992 escape_kotlin(&json_str)
993 );
994 }
995 }
996
997 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
998
999 if let Some(factory) = client_factory {
1004 let fixture_id = &fixture.id;
1005 let mock_url_expr = format!("System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"");
1006 for line in &setup_lines {
1007 let _ = writeln!(out, " {line}");
1008 }
1009 let _ = writeln!(
1010 out,
1011 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1012 );
1013 if expects_error {
1014 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1015 let _ = writeln!(out, " client.{function_name}({args_str})");
1016 let _ = writeln!(out, " }}");
1017 let _ = writeln!(out, " client.close()");
1018 let _ = writeln!(out, " }}");
1019 return;
1020 }
1021 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
1022 if !collect_snippet.is_empty() {
1023 let _ = writeln!(out, " {collect_snippet}");
1024 }
1025 for assertion in &fixture.assertions {
1026 render_assertion(
1027 out,
1028 assertion,
1029 result_var,
1030 class_name,
1031 field_resolver,
1032 result_is_simple,
1033 enum_fields,
1034 );
1035 }
1036 let _ = writeln!(out, " client.close()");
1037 let _ = writeln!(out, " }}");
1038 return;
1039 }
1040
1041 if expects_error {
1043 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1046 for line in &setup_lines {
1047 let _ = writeln!(out, " {line}");
1048 }
1049 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
1050 let _ = writeln!(out, " }}");
1051 let _ = writeln!(out, " }}");
1052 return;
1053 }
1054
1055 for line in &setup_lines {
1056 let _ = writeln!(out, " {line}");
1057 }
1058
1059 let _ = writeln!(
1060 out,
1061 " val {result_var} = {class_name}.{function_name}({args_str})"
1062 );
1063
1064 if !collect_snippet.is_empty() {
1065 let _ = writeln!(out, " {collect_snippet}");
1066 }
1067
1068 for assertion in &fixture.assertions {
1069 render_assertion(
1070 out,
1071 assertion,
1072 result_var,
1073 class_name,
1074 field_resolver,
1075 result_is_simple,
1076 enum_fields,
1077 );
1078 }
1079
1080 let _ = writeln!(out, " }}");
1081}
1082
1083fn build_args_and_setup(
1087 input: &serde_json::Value,
1088 args: &[crate::config::ArgMapping],
1089 class_name: &str,
1090 options_type: Option<&str>,
1091 fixture_id: &str,
1092) -> (Vec<String>, String) {
1093 if args.is_empty() {
1094 return (Vec::new(), String::new());
1095 }
1096
1097 let mut setup_lines: Vec<String> = Vec::new();
1098 let mut parts: Vec<String> = Vec::new();
1099
1100 for arg in args {
1101 if arg.arg_type == "mock_url" {
1102 setup_lines.push(format!(
1103 "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
1104 arg.name,
1105 ));
1106 parts.push(arg.name.clone());
1107 continue;
1108 }
1109
1110 if arg.arg_type == "handle" {
1111 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1112 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1113 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1114 if config_value.is_null()
1115 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1116 {
1117 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1118 } else {
1119 let json_str = serde_json::to_string(config_value).unwrap_or_default();
1120 let name = &arg.name;
1121 setup_lines.push(format!(
1122 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1123 escape_kotlin(&json_str),
1124 ));
1125 setup_lines.push(format!(
1126 "val {} = {class_name}.{constructor_name}({name}Config)",
1127 arg.name,
1128 name = name,
1129 ));
1130 }
1131 parts.push(arg.name.clone());
1132 continue;
1133 }
1134
1135 let val_resolved = super::resolve_field(input, &arg.field);
1137 let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1138 None
1139 } else {
1140 Some(val_resolved)
1141 };
1142 match val {
1143 None | Some(serde_json::Value::Null) if arg.optional => {
1144 if arg.arg_type == "json_object" {
1149 if let Some(opts_type) = options_type {
1150 parts.push(format!("{opts_type}.builder().build()"));
1151 } else {
1152 parts.push("null".to_string());
1153 }
1154 } else {
1155 parts.push("null".to_string());
1156 }
1157 }
1158 None | Some(serde_json::Value::Null) => {
1159 let default_val = match arg.arg_type.as_str() {
1160 "string" => "\"\"".to_string(),
1161 "int" | "integer" => "0".to_string(),
1162 "float" | "number" => "0.0".to_string(),
1163 "bool" | "boolean" => "false".to_string(),
1164 _ => "null".to_string(),
1165 };
1166 parts.push(default_val);
1167 }
1168 Some(v) => {
1169 if arg.arg_type == "json_object" && v.is_array() {
1174 if let Some(elem) = &arg.element_type {
1175 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1176 parts.push(emit_kotlin_batch_item_array(v, elem));
1177 continue;
1178 }
1179 let items: Vec<String> = v
1181 .as_array()
1182 .map(|arr| arr.iter().map(json_to_kotlin).collect())
1183 .unwrap_or_default();
1184 parts.push(format!("listOf({})", items.join(", ")));
1185 continue;
1186 }
1187 }
1188 if arg.arg_type == "json_object" && options_type.is_some() {
1190 parts.push(arg.name.clone());
1191 continue;
1192 }
1193 if arg.arg_type == "bytes" {
1195 let val = json_to_kotlin(v);
1196 parts.push(format!("{val}.toByteArray()"));
1197 continue;
1198 }
1199 if arg.arg_type == "file_path" {
1203 let val = json_to_kotlin(v);
1204 parts.push(format!("java.nio.file.Path.of({val})"));
1205 continue;
1206 }
1207 parts.push(json_to_kotlin(v));
1208 }
1209 }
1210 }
1211
1212 (setup_lines, parts.join(", "))
1213}
1214
1215fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1219 let Some(items) = arr.as_array() else {
1220 return "emptyList()".to_string();
1221 };
1222 let parts: Vec<String> = items
1223 .iter()
1224 .filter_map(|item| {
1225 let obj = item.as_object()?;
1226 match elem_type {
1227 "BatchBytesItem" => {
1228 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1229 let content_code = obj
1230 .get("content")
1231 .and_then(|v| v.as_array())
1232 .map(|arr| {
1233 let bytes: Vec<String> =
1234 arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1235 format!("byteArrayOf({})", bytes.join(", "))
1236 })
1237 .unwrap_or_else(|| "byteArrayOf()".to_string());
1238 Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1239 }
1240 "BatchFileItem" => {
1241 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1242 Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1243 }
1244 _ => None,
1245 }
1246 })
1247 .collect();
1248 format!("listOf({})", parts.join(", "))
1249}
1250
1251fn render_assertion(
1252 out: &mut String,
1253 assertion: &Assertion,
1254 result_var: &str,
1255 _class_name: &str,
1256 field_resolver: &FieldResolver,
1257 result_is_simple: bool,
1258 enum_fields: &HashSet<String>,
1259) {
1260 if let Some(f) = &assertion.field {
1263 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1264 if let Some(expr) =
1265 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "kotlin", "chunks")
1266 {
1267 let line = match assertion.assertion_type.as_str() {
1268 "count_min" => {
1269 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1270 format!(" assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1271 } else {
1272 String::new()
1273 }
1274 }
1275 "count_equals" => {
1276 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1277 format!(
1278 " assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1279 )
1280 } else {
1281 String::new()
1282 }
1283 }
1284 "equals" => {
1285 if let Some(serde_json::Value::String(s)) = &assertion.value {
1286 let escaped = escape_kotlin(s);
1287 format!(" assertEquals(\"{escaped}\", {expr})\n")
1288 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1289 format!(" assertEquals({b}, {expr})\n")
1290 } else {
1291 String::new()
1292 }
1293 }
1294 "not_empty" => {
1295 format!(" assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1296 }
1297 "is_empty" => {
1298 format!(" assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1299 }
1300 "is_true" => {
1301 format!(" assertTrue({expr}, \"expected true\")\n")
1302 }
1303 "is_false" => {
1304 format!(" assertFalse({expr}, \"expected false\")\n")
1305 }
1306 "greater_than" => {
1307 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1308 format!(" assertTrue({expr} > {n}, \"expected > {n}\")\n")
1309 } else {
1310 String::new()
1311 }
1312 }
1313 "contains" => {
1314 if let Some(serde_json::Value::String(s)) = &assertion.value {
1315 let escaped = escape_kotlin(s);
1316 format!(
1317 " assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1318 )
1319 } else {
1320 String::new()
1321 }
1322 }
1323 _ => format!(
1324 " // streaming field '{f}': assertion type '{}' not rendered\n",
1325 assertion.assertion_type
1326 ),
1327 };
1328 if !line.is_empty() {
1329 out.push_str(&line);
1330 }
1331 }
1332 return;
1333 }
1334 }
1335
1336 if let Some(f) = &assertion.field {
1338 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1339 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1340 return;
1341 }
1342 }
1343
1344 let field_is_enum = assertion
1346 .field
1347 .as_deref()
1348 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1349
1350 let field_expr = if result_is_simple {
1352 result_var.to_string()
1353 } else {
1354 match &assertion.field {
1355 Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1356 _ => result_var.to_string(),
1357 }
1358 };
1359
1360 let field_is_optional = !result_is_simple
1364 && assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1365 let resolved = field_resolver.resolve(f);
1366 if field_resolver.has_map_access(f) {
1367 return false;
1368 }
1369 if field_resolver.is_optional(resolved) {
1371 return true;
1372 }
1373 let mut prefix = String::new();
1376 for part in resolved.split('.') {
1377 let key = part.split('[').next().unwrap_or(part);
1379 if !prefix.is_empty() {
1380 prefix.push('.');
1381 }
1382 prefix.push_str(key);
1383 if field_resolver.is_optional(&prefix) {
1384 return true;
1385 }
1386 }
1387 false
1388 });
1389
1390 let string_field_expr = if field_is_optional {
1393 format!("{field_expr}.orEmpty()")
1394 } else {
1395 field_expr.clone()
1396 };
1397
1398 let nonnull_field_expr = if field_is_optional {
1401 format!("{field_expr}!!")
1402 } else {
1403 field_expr.clone()
1404 };
1405
1406 let string_expr = if field_is_enum {
1408 format!("{string_field_expr}.getValue()")
1409 } else {
1410 string_field_expr.clone()
1411 };
1412
1413 match assertion.assertion_type.as_str() {
1414 "equals" => {
1415 if let Some(expected) = &assertion.value {
1416 let kotlin_val = json_to_kotlin(expected);
1417 if expected.is_string() {
1418 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1419 } else {
1420 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1421 }
1422 }
1423 }
1424 "contains" => {
1425 if let Some(expected) = &assertion.value {
1426 let kotlin_val = json_to_kotlin(expected);
1427 let _ = writeln!(
1428 out,
1429 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1430 );
1431 }
1432 }
1433 "contains_all" => {
1434 if let Some(values) = &assertion.values {
1435 for val in values {
1436 let kotlin_val = json_to_kotlin(val);
1437 let _ = writeln!(
1438 out,
1439 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1440 );
1441 }
1442 }
1443 }
1444 "not_contains" => {
1445 if let Some(expected) = &assertion.value {
1446 let kotlin_val = json_to_kotlin(expected);
1447 let _ = writeln!(
1448 out,
1449 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1450 );
1451 }
1452 }
1453 "not_empty" => {
1454 if field_is_optional {
1459 let _ = writeln!(
1460 out,
1461 " assertTrue({field_expr} != null, \"expected non-empty value\")"
1462 );
1463 } else {
1464 let _ = writeln!(
1465 out,
1466 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1467 );
1468 }
1469 }
1470 "is_empty" => {
1471 if field_is_optional {
1472 let _ = writeln!(
1473 out,
1474 " assertTrue({field_expr} == null, \"expected empty value\")"
1475 );
1476 } else {
1477 let _ = writeln!(
1478 out,
1479 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1480 );
1481 }
1482 }
1483 "contains_any" => {
1484 if let Some(values) = &assertion.values {
1485 let checks: Vec<String> = values
1486 .iter()
1487 .map(|v| {
1488 let kotlin_val = json_to_kotlin(v);
1489 format!("{string_expr}.contains({kotlin_val})")
1490 })
1491 .collect();
1492 let joined = checks.join(" || ");
1493 let _ = writeln!(
1494 out,
1495 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1496 );
1497 }
1498 }
1499 "greater_than" => {
1500 if let Some(val) = &assertion.value {
1501 let kotlin_val = json_to_kotlin(val);
1502 let _ = writeln!(
1503 out,
1504 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
1505 );
1506 }
1507 }
1508 "less_than" => {
1509 if let Some(val) = &assertion.value {
1510 let kotlin_val = json_to_kotlin(val);
1511 let _ = writeln!(
1512 out,
1513 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
1514 );
1515 }
1516 }
1517 "greater_than_or_equal" => {
1518 if let Some(val) = &assertion.value {
1519 let kotlin_val = json_to_kotlin(val);
1520 let _ = writeln!(
1521 out,
1522 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
1523 );
1524 }
1525 }
1526 "less_than_or_equal" => {
1527 if let Some(val) = &assertion.value {
1528 let kotlin_val = json_to_kotlin(val);
1529 let _ = writeln!(
1530 out,
1531 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
1532 );
1533 }
1534 }
1535 "starts_with" => {
1536 if let Some(expected) = &assertion.value {
1537 let kotlin_val = json_to_kotlin(expected);
1538 let _ = writeln!(
1539 out,
1540 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1541 );
1542 }
1543 }
1544 "ends_with" => {
1545 if let Some(expected) = &assertion.value {
1546 let kotlin_val = json_to_kotlin(expected);
1547 let _ = writeln!(
1548 out,
1549 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1550 );
1551 }
1552 }
1553 "min_length" => {
1554 if let Some(val) = &assertion.value {
1555 if let Some(n) = val.as_u64() {
1556 let _ = writeln!(
1557 out,
1558 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1559 );
1560 }
1561 }
1562 }
1563 "max_length" => {
1564 if let Some(val) = &assertion.value {
1565 if let Some(n) = val.as_u64() {
1566 let _ = writeln!(
1567 out,
1568 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1569 );
1570 }
1571 }
1572 }
1573 "count_min" => {
1574 if let Some(val) = &assertion.value {
1575 if let Some(n) = val.as_u64() {
1576 let _ = writeln!(
1577 out,
1578 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1579 );
1580 }
1581 }
1582 }
1583 "count_equals" => {
1584 if let Some(val) = &assertion.value {
1585 if let Some(n) = val.as_u64() {
1586 let _ = writeln!(
1587 out,
1588 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1589 );
1590 }
1591 }
1592 }
1593 "is_true" => {
1594 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
1595 }
1596 "is_false" => {
1597 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
1598 }
1599 "matches_regex" => {
1600 if let Some(expected) = &assertion.value {
1601 let kotlin_val = json_to_kotlin(expected);
1602 let _ = writeln!(
1603 out,
1604 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1605 );
1606 }
1607 }
1608 "not_error" => {
1609 }
1611 "error" => {
1612 }
1614 "method_result" => {
1615 let _ = writeln!(
1617 out,
1618 " // method_result assertions not yet implemented for Kotlin"
1619 );
1620 }
1621 other => {
1622 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1623 }
1624 }
1625}
1626
1627fn json_to_kotlin(value: &serde_json::Value) -> String {
1629 match value {
1630 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1631 serde_json::Value::Bool(b) => b.to_string(),
1632 serde_json::Value::Number(n) => {
1633 if n.is_f64() {
1634 format!("{}d", n)
1635 } else {
1636 n.to_string()
1637 }
1638 }
1639 serde_json::Value::Null => "null".to_string(),
1640 serde_json::Value::Array(arr) => {
1641 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1642 format!("listOf({})", items.join(", "))
1643 }
1644 serde_json::Value::Object(_) => {
1645 let json_str = serde_json::to_string(value).unwrap_or_default();
1646 format!("\"{}\"", escape_kotlin(&json_str))
1647 }
1648 }
1649}