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 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
146 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
147 .iter()
148 .filter_map(|td| {
149 let enum_field_names: HashSet<String> = td
150 .fields
151 .iter()
152 .filter(|field| is_enum_typed(&field.ty, &struct_names))
153 .map(|field| field.name.clone())
154 .collect();
155 if enum_field_names.is_empty() {
156 None
157 } else {
158 Some((td.name.clone(), enum_field_names))
159 }
160 })
161 .collect();
162
163 for group in groups {
164 let active: Vec<&Fixture> = group
165 .fixtures
166 .iter()
167 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
168 .collect();
169
170 if active.is_empty() {
171 continue;
172 }
173
174 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
175 let content = render_test_file(
176 &group.category,
177 &active,
178 &class_name,
179 &function_name,
180 &kotlin_pkg_id,
181 result_var,
182 &e2e_config.call.args,
183 options_type.as_deref(),
184 &field_resolver,
185 result_is_simple,
186 &e2e_config.fields_enum,
187 e2e_config,
188 &type_enum_fields,
189 );
190 files.push(GeneratedFile {
191 path: test_base.join(class_file_name),
192 content,
193 generated_header: true,
194 });
195 }
196
197 Ok(files)
198 }
199
200 fn language_name(&self) -> &'static str {
201 "kotlin"
202 }
203}
204
205fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
213 use alef_core::ir::TypeRef;
214 match ty {
215 TypeRef::Named(name) => !struct_names.contains(name.as_str()),
216 TypeRef::Optional(inner) => {
217 matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
218 }
219 _ => false,
220 }
221}
222
223fn render_build_gradle(
228 pkg_name: &str,
229 kotlin_pkg_id: &str,
230 pkg_version: &str,
231 dep_mode: crate::config::DependencyMode,
232 needs_mock_server: bool,
233) -> String {
234 let dep_block = match dep_mode {
235 crate::config::DependencyMode::Registry => {
236 format!(r#" testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
238 }
239 crate::config::DependencyMode::Local => {
240 let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name).replace('-', "_");
247 let jna = maven::JNA;
248 let jackson = maven::JACKSON_E2E;
249 let jspecify = maven::JSPECIFY;
250 let coroutines = maven::KOTLINX_COROUTINES_CORE;
251 format!(
252 r#" testImplementation(files("../../packages/kotlin/build/libs/{jar_name}-{pkg_version}.jar"))
253 testImplementation("net.java.dev.jna:jna:{jna}")
254 testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
255 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
256 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
257 testImplementation("org.jspecify:jspecify:{jspecify}")
258 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")"#
259 )
260 }
261 };
262
263 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
264 let junit = maven::JUNIT;
265 let jackson = maven::JACKSON_E2E;
266 let jvm_target = toolchain::JVM_TARGET;
267 let launcher_dep = if needs_mock_server {
268 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
269 } else {
270 String::new()
271 };
272 format!(
273 r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
274
275plugins {{
276 kotlin("jvm") version "{kotlin_plugin}"
277}}
278
279group = "{kotlin_pkg_id}"
280version = "0.1.0"
281
282java {{
283 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
284 targetCompatibility = JavaVersion.VERSION_{jvm_target}
285}}
286
287kotlin {{
288 compilerOptions {{
289 jvmTarget.set(JvmTarget.JVM_{jvm_target})
290 }}
291}}
292
293repositories {{
294 mavenCentral()
295}}
296
297dependencies {{
298{dep_block}
299 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
300 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
301{launcher_dep}
302 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
303 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
304 testImplementation(kotlin("test"))
305}}
306
307tasks.test {{
308 useJUnitPlatform()
309 val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
310 systemProperty("java.library.path", libPath)
311 systemProperty("jna.library.path", libPath)
312 // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/.
313 workingDir = file("${{rootDir}}/../../test_documents")
314}}
315"#
316 )
317}
318
319fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
328 let header = hash::header(CommentStyle::DoubleSlash);
329 format!(
330 r#"{header}package {kotlin_pkg_id}.e2e
331
332import java.io.BufferedReader
333import java.io.IOException
334import java.io.InputStreamReader
335import java.nio.charset.StandardCharsets
336import java.nio.file.Path
337import java.nio.file.Paths
338import java.util.regex.Pattern
339import org.junit.platform.launcher.LauncherSession
340import org.junit.platform.launcher.LauncherSessionListener
341
342/**
343 * Spawns the mock-server binary once per JUnit launcher session and
344 * exposes its URL as the `mockServerUrl` system property. Generated
345 * test bodies read the property (with `MOCK_SERVER_URL` env-var
346 * fallback) so tests can run via plain `./gradlew test` without any
347 * external mock-server orchestration. Mirrors the Ruby spec_helper /
348 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
349 * skipping the spawn entirely.
350 */
351class MockServerListener : LauncherSessionListener {{
352 private var mockServer: Process? = null
353
354 override fun launcherSessionOpened(session: LauncherSession) {{
355 val preset = System.getenv("MOCK_SERVER_URL")
356 if (!preset.isNullOrEmpty()) {{
357 System.setProperty("mockServerUrl", preset)
358 return
359 }}
360 val repoRoot = locateRepoRoot()
361 ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
362 val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
363 val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
364 val fixturesDir = repoRoot.resolve("fixtures").toFile()
365 check(bin.exists()) {{
366 "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
367 }}
368 val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
369 .redirectErrorStream(false)
370 val server = try {{
371 pb.start()
372 }} catch (e: IOException) {{
373 throw IllegalStateException("MockServerListener: failed to start mock-server", e)
374 }}
375 mockServer = server
376 // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
377 // Cap the loop so a misbehaving mock-server cannot block indefinitely.
378 val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
379 var url: String? = null
380 try {{
381 for (i in 0 until 16) {{
382 val line = stdout.readLine() ?: break
383 when {{
384 line.startsWith("MOCK_SERVER_URL=") -> {{
385 url = line.removePrefix("MOCK_SERVER_URL=").trim()
386 }}
387 line.startsWith("MOCK_SERVERS=") -> {{
388 val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
389 System.setProperty("mockServers", jsonVal)
390 // Parse JSON map of fixture_id -> url and expose as system properties.
391 val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
392 val matcher = p.matcher(jsonVal)
393 while (matcher.find()) {{
394 System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
395 }}
396 break
397 }}
398 url != null -> break
399 }}
400 }}
401 }} catch (e: IOException) {{
402 server.destroyForcibly()
403 throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
404 }}
405 if (url.isNullOrEmpty()) {{
406 server.destroyForcibly()
407 error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
408 }}
409 // TCP-readiness probe: ensure axum::serve is accepting before tests start.
410 // The mock-server binds the TcpListener synchronously then prints the URL
411 // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
412 // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
413 val healthUri = java.net.URI.create(url)
414 val host = healthUri.host
415 val port = healthUri.port
416 val deadline = System.nanoTime() + 5_000_000_000L
417 while (System.nanoTime() < deadline) {{
418 try {{
419 java.net.Socket().use {{ s ->
420 s.connect(java.net.InetSocketAddress(host, port), 100)
421 break
422 }}
423 }} catch (_: java.io.IOException) {{
424 try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
425 }}
426 }}
427 System.setProperty("mockServerUrl", url)
428 // Drain remaining stdout/stderr in daemon threads so a full pipe
429 // does not block the child.
430 Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
431 Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
432 }}
433
434 override fun launcherSessionClosed(session: LauncherSession) {{
435 val server = mockServer ?: return
436 try {{ server.outputStream.close() }} catch (_: IOException) {{}}
437 try {{
438 if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
439 server.destroyForcibly()
440 }}
441 }} catch (ie: InterruptedException) {{
442 Thread.currentThread().interrupt()
443 server.destroyForcibly()
444 }}
445 }}
446
447 companion object {{
448 private fun locateRepoRoot(): Path? {{
449 var dir: Path? = Paths.get("").toAbsolutePath()
450 while (dir != null) {{
451 if (dir.resolve("fixtures").toFile().isDirectory
452 && dir.resolve("e2e").toFile().isDirectory) {{
453 return dir
454 }}
455 dir = dir.parent
456 }}
457 return null
458 }}
459
460 private fun drain(reader: BufferedReader) {{
461 try {{
462 val buf = CharArray(1024)
463 while (reader.read(buf) >= 0) {{ /* drain */ }}
464 }} catch (_: IOException) {{}}
465 }}
466 }}
467}}
468"#
469 )
470}
471
472#[allow(clippy::too_many_arguments)]
473fn render_test_file(
474 category: &str,
475 fixtures: &[&Fixture],
476 class_name: &str,
477 function_name: &str,
478 kotlin_pkg_id: &str,
479 result_var: &str,
480 args: &[crate::config::ArgMapping],
481 options_type: Option<&str>,
482 field_resolver: &FieldResolver,
483 result_is_simple: bool,
484 enum_fields: &HashSet<String>,
485 e2e_config: &E2eConfig,
486 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
487) -> String {
488 let mut out = String::new();
489 out.push_str(&hash::header(CommentStyle::DoubleSlash));
490 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
491
492 let (import_path, simple_class) = if class_name.contains('.') {
495 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
496 (class_name, simple)
497 } else {
498 ("", class_name)
499 };
500
501 let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
502 let _ = writeln!(out);
503
504 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
506
507 let has_client_factory_fixtures = fixtures.iter().any(|f| {
510 if f.is_http_test() {
511 return false;
512 }
513 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
514 let per_call_factory = cc.overrides.get("kotlin").and_then(|o| o.client_factory.as_deref());
515 let global_factory = e2e_config
516 .call
517 .overrides
518 .get("kotlin")
519 .and_then(|o| o.client_factory.as_deref());
520 per_call_factory.or(global_factory).is_some()
521 });
522
523 let mut per_fixture_options_types: HashSet<String> = HashSet::new();
527 for f in fixtures.iter() {
528 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
529 let call_overrides = cc.overrides.get("kotlin");
530 let effective_opts: Option<String> = call_overrides
531 .and_then(|o| o.options_type.clone())
532 .or_else(|| options_type.map(|s| s.to_string()))
533 .or_else(|| {
534 for cand in ["csharp", "c", "go", "php", "python"] {
535 if let Some(o) = cc.overrides.get(cand) {
536 if let Some(t) = &o.options_type {
537 return Some(t.clone());
538 }
539 }
540 }
541 None
542 });
543 if let Some(opts) = effective_opts {
544 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
547 let needs_opts_type = fixture_args.iter().any(|arg| {
552 if arg.arg_type != "json_object" {
553 return false;
554 }
555 let v = super::resolve_field(&f.input, &arg.field);
556 !v.is_null() || arg.optional
557 });
558 if needs_opts_type {
559 per_fixture_options_types.insert(opts.to_string());
560 }
561 }
562 }
563 let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
564 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
566 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
567 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
568 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
569 })
570 });
571 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
573
574 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
575 let _ = writeln!(out, "import kotlin.test.assertEquals");
576 let _ = writeln!(out, "import kotlin.test.assertTrue");
577 let _ = writeln!(out, "import kotlin.test.assertFalse");
578 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
579 if has_client_factory_fixtures {
580 let _ = writeln!(out, "import kotlinx.coroutines.runBlocking");
581 }
582 let binding_pkg_for_imports: String = if !import_path.is_empty() {
588 import_path
589 .rsplit_once('.')
590 .map(|(p, _)| p.to_string())
591 .unwrap_or_else(|| kotlin_pkg_id.to_string())
592 } else {
593 kotlin_pkg_id.to_string()
594 };
595 let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
597 if has_call_fixtures {
598 if !import_path.is_empty() {
599 let _ = writeln!(out, "import {import_path}");
600 } else if !class_name.is_empty() {
601 let _ = writeln!(out, "import {binding_pkg_for_imports}.{class_name}");
602 }
603 }
604 if needs_object_mapper {
605 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
606 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
607 }
608 if has_call_fixtures {
612 let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
613 sorted_opts.sort();
614 for opts_type in sorted_opts {
615 let _ = writeln!(out, "import {binding_pkg_for_imports}.{opts_type}");
616 }
617 }
618 if needs_object_mapper_for_handle {
620 let _ = writeln!(out, "import {binding_pkg_for_imports}.CrawlConfig");
621 }
622 let mut batch_elem_imports: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
625 for f in fixtures.iter() {
626 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
627 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
628 for arg in fixture_args.iter() {
629 if arg.arg_type != "json_object" {
630 continue;
631 }
632 let v = super::resolve_field(&f.input, &arg.field);
633 if !v.is_array() {
634 continue;
635 }
636 if let Some(elem) = &arg.element_type {
637 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
638 batch_elem_imports.insert(elem.clone());
639 }
640 }
641 }
642 }
643 for elem in &batch_elem_imports {
644 let _ = writeln!(out, "import {binding_pkg_for_imports}.{elem}");
645 }
646 let _ = writeln!(out);
647
648 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
649 let _ = writeln!(out, "class {test_class_name} {{");
650
651 if needs_object_mapper {
652 let _ = writeln!(out);
653 let _ = writeln!(out, " companion object {{");
654 let _ = writeln!(
655 out,
656 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module()).setPropertyNamingStrategy(com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE)"
657 );
658 let _ = writeln!(out, " }}");
659 }
660
661 for fixture in fixtures {
662 render_test_method(
663 &mut out,
664 fixture,
665 simple_class,
666 function_name,
667 result_var,
668 args,
669 options_type,
670 field_resolver,
671 result_is_simple,
672 enum_fields,
673 e2e_config,
674 type_enum_fields,
675 );
676 let _ = writeln!(out);
677 }
678
679 let _ = writeln!(out, "}}");
680 out
681}
682
683struct KotlinTestClientRenderer;
690
691impl client::TestClientRenderer for KotlinTestClientRenderer {
692 fn language_name(&self) -> &'static str {
693 "kotlin"
694 }
695
696 fn sanitize_test_name(&self, id: &str) -> String {
697 sanitize_ident(id).to_upper_camel_case()
698 }
699
700 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
701 let _ = writeln!(out, " @Test");
702 let _ = writeln!(out, " fun test{fn_name}() {{");
703 let _ = writeln!(out, " // {description}");
704 if let Some(reason) = skip_reason {
705 let escaped = escape_kotlin(reason);
706 let _ = writeln!(
707 out,
708 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
709 );
710 }
711 }
712
713 fn render_test_close(&self, out: &mut String) {
714 let _ = writeln!(out, " }}");
715 }
716
717 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
718 let method = ctx.method.to_uppercase();
719 let fixture_path = ctx.path;
720
721 const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
723
724 let _ = writeln!(
725 out,
726 " val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
727 );
728 let _ = writeln!(out, " val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
729
730 let body_publisher = if let Some(body) = ctx.body {
731 let json = serde_json::to_string(body).unwrap_or_default();
732 let escaped = escape_kotlin(&json);
733 format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
734 } else {
735 "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
736 };
737
738 let _ = writeln!(out, " val builder = java.net.http.HttpRequest.newBuilder(uri)");
739 let _ = writeln!(out, " .method(\"{method}\", {body_publisher})");
740
741 if ctx.body.is_some() {
743 let content_type = ctx.content_type.unwrap_or("application/json");
744 let _ = writeln!(out, " .header(\"Content-Type\", \"{content_type}\")");
745 }
746
747 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
749 header_pairs.sort_by_key(|(k, _)| k.as_str());
750 for (name, value) in &header_pairs {
751 if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
752 continue;
753 }
754 let escaped_name = escape_kotlin(name);
755 let escaped_value = escape_kotlin(value);
756 let _ = writeln!(out, " .header(\"{escaped_name}\", \"{escaped_value}\")");
757 }
758
759 if !ctx.cookies.is_empty() {
761 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
762 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
763 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
764 let cookie_header = escape_kotlin(&cookie_str.join("; "));
765 let _ = writeln!(out, " .header(\"Cookie\", \"{cookie_header}\")");
766 }
767
768 let _ = writeln!(
769 out,
770 " val {} = java.net.http.HttpClient.newHttpClient()",
771 ctx.response_var
772 );
773 let _ = writeln!(
774 out,
775 " .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
776 );
777 }
778
779 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
780 let _ = writeln!(
781 out,
782 " assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
783 );
784 }
785
786 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
787 let escaped_name = escape_kotlin(name);
788 match expected {
789 "<<present>>" => {
790 let _ = writeln!(
791 out,
792 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
793 );
794 }
795 "<<absent>>" => {
796 let _ = writeln!(
797 out,
798 " assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
799 );
800 }
801 "<<uuid>>" => {
802 let _ = writeln!(
803 out,
804 " 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\")"
805 );
806 }
807 exact => {
808 let escaped_value = escape_kotlin(exact);
809 let _ = writeln!(
810 out,
811 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
812 );
813 }
814 }
815 }
816
817 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
818 match expected {
819 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
820 let json_str = serde_json::to_string(expected).unwrap_or_default();
821 let escaped = escape_kotlin(&json_str);
822 let _ = writeln!(out, " val bodyJson = MAPPER.readTree({response_var}.body())");
823 let _ = writeln!(out, " val expectedJson = MAPPER.readTree(\"{escaped}\")");
824 let _ = writeln!(out, " assertEquals(expectedJson, bodyJson, \"body mismatch\")");
825 }
826 serde_json::Value::String(s) => {
827 let escaped = escape_kotlin(s);
828 let _ = writeln!(
829 out,
830 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
831 );
832 }
833 other => {
834 let escaped = escape_kotlin(&other.to_string());
835 let _ = writeln!(
836 out,
837 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
838 );
839 }
840 }
841 }
842
843 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
844 if let Some(obj) = expected.as_object() {
845 let _ = writeln!(out, " val _partialTree = MAPPER.readTree({response_var}.body())");
846 for (key, val) in obj {
847 let escaped_key = escape_kotlin(key);
848 match val {
849 serde_json::Value::String(s) => {
850 let escaped_val = escape_kotlin(s);
851 let _ = writeln!(
852 out,
853 " assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
854 );
855 }
856 serde_json::Value::Bool(b) => {
857 let _ = writeln!(
858 out,
859 " assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
860 );
861 }
862 serde_json::Value::Number(n) => {
863 let _ = writeln!(
864 out,
865 " assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
866 );
867 }
868 other => {
869 let json_str = serde_json::to_string(other).unwrap_or_default();
870 let escaped_val = escape_kotlin(&json_str);
871 let _ = writeln!(
872 out,
873 " assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
874 );
875 }
876 }
877 }
878 }
879 }
880
881 fn render_assert_validation_errors(
882 &self,
883 out: &mut String,
884 response_var: &str,
885 errors: &[ValidationErrorExpectation],
886 ) {
887 let _ = writeln!(out, " val _veTree = MAPPER.readTree({response_var}.body())");
888 let _ = writeln!(out, " val _veErrors = _veTree.path(\"errors\")");
889 for ve in errors {
890 let escaped_msg = escape_kotlin(&ve.msg);
891 let _ = writeln!(
892 out,
893 " assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
894 );
895 }
896 }
897}
898
899fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
904 if http.expected_response.status_code == 101 {
906 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
907 let description = &fixture.description;
908 let _ = writeln!(out, " @Test");
909 let _ = writeln!(out, " fun test{method_name}() {{");
910 let _ = writeln!(out, " // {description}");
911 let _ = writeln!(
912 out,
913 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
914 );
915 let _ = writeln!(out, " }}");
916 return;
917 }
918
919 client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
920}
921
922#[allow(clippy::too_many_arguments)]
923fn render_test_method(
924 out: &mut String,
925 fixture: &Fixture,
926 class_name: &str,
927 _function_name: &str,
928 _result_var: &str,
929 _args: &[crate::config::ArgMapping],
930 options_type: Option<&str>,
931 field_resolver: &FieldResolver,
932 result_is_simple: bool,
933 enum_fields: &HashSet<String>,
934 e2e_config: &E2eConfig,
935 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
936) {
937 if let Some(http) = &fixture.http {
939 render_http_test_method(out, fixture, http);
940 return;
941 }
942
943 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
945 let lang = "kotlin";
946 let call_overrides = call_config.overrides.get(lang);
947
948 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
952 e2e_config
953 .call
954 .overrides
955 .get(lang)
956 .and_then(|o| o.client_factory.as_deref())
957 });
958
959 let effective_function_name = call_overrides
960 .and_then(|o| o.function.as_ref())
961 .cloned()
962 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
963 let effective_result_var = &call_config.result_var;
964 let effective_args = &call_config.args;
965 let function_name = effective_function_name.as_str();
966 let result_var = effective_result_var.as_str();
967 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
968 let effective_options_type: Option<String> = call_overrides
973 .and_then(|o| o.options_type.clone())
974 .or_else(|| options_type.map(|s| s.to_string()))
975 .or_else(|| {
976 for cand in ["csharp", "c", "go", "php", "python"] {
977 if let Some(o) = call_config.overrides.get(cand) {
978 if let Some(t) = &o.options_type {
979 return Some(t.clone());
980 }
981 }
982 }
983 None
984 });
985 let options_type = effective_options_type.as_deref();
986
987 let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple)
992 || call_config.result_is_simple
993 || result_is_simple
994 || ["java", "csharp", "go"]
995 .iter()
996 .any(|cand| call_config.overrides.get(*cand).is_some_and(|o| o.result_is_simple));
997 let result_is_simple = effective_result_is_simple;
998
999 let result_is_option = call_overrides.is_some_and(|o| o.result_is_option) || call_config.result_is_option;
1003
1004 let method_name = fixture.id.to_upper_camel_case();
1005 let description = &fixture.description;
1006 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1007
1008 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1010 let collect_snippet = if is_streaming && !expects_error {
1011 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("kotlin", result_var, "chunks")
1012 .unwrap_or_default()
1013 } else {
1014 String::new()
1015 };
1016
1017 let needs_deser = options_type.is_some()
1021 && args
1022 .iter()
1023 .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&fixture.input, &arg.field).is_null());
1024
1025 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
1036 let result_type_name: Option<&str> = call_overrides
1039 .and_then(|co| co.result_type.as_deref())
1040 .or_else(|| call_config.overrides.get("java").and_then(|o| o.result_type.as_deref()))
1041 .or_else(|| call_config.overrides.get("c").and_then(|o| o.result_type.as_deref()));
1042 let auto_enum_fields: Option<&HashSet<String>> = result_type_name.and_then(|name| type_enum_fields.get(name));
1043 let has_per_call = call_overrides.is_some_and(|co| !co.enum_fields.is_empty());
1044 let has_auto = auto_enum_fields.is_some_and(|f| !f.is_empty());
1045 if has_per_call || has_auto {
1046 let mut merged = enum_fields.clone();
1047 if let Some(co) = call_overrides {
1048 merged.extend(co.enum_fields.keys().cloned());
1049 }
1050 if let Some(auto_fields) = auto_enum_fields {
1051 merged.extend(auto_fields.iter().cloned());
1052 }
1053 std::borrow::Cow::Owned(merged)
1054 } else {
1055 std::borrow::Cow::Borrowed(enum_fields)
1056 }
1057 };
1058 let enum_fields: &HashSet<String> = &effective_enum_fields;
1059
1060 let _ = writeln!(out, " @Test");
1061 if client_factory.is_some() {
1062 let _ = writeln!(out, " fun test{method_name}() = runBlocking {{");
1063 } else {
1064 let _ = writeln!(out, " fun test{method_name}() {{");
1065 }
1066 let _ = writeln!(out, " // {description}");
1067
1068 if needs_deser {
1074 for arg in args {
1075 if arg.arg_type != "json_object" {
1076 continue;
1077 }
1078 let val = super::resolve_field(&fixture.input, &arg.field);
1079 if val.is_null() {
1080 continue;
1081 }
1082 if val.is_array() && arg.element_type.is_some() {
1085 continue;
1086 }
1087 let Some(opts_type) = options_type else { continue };
1088 let normalized = super::transform_json_keys_for_language(val, "snake_case");
1089 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
1090 let var_name = &arg.name;
1091 let _ = writeln!(
1092 out,
1093 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
1094 escape_kotlin(&json_str)
1095 );
1096 }
1097 }
1098
1099 let (setup_lines, args_str) =
1100 build_args_and_setup(fixture, &fixture.input, args, class_name, options_type, &fixture.id);
1101
1102 if let Some(factory) = client_factory {
1107 let fixture_id = &fixture.id;
1108 let mock_url_expr = format!("System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"");
1109 for line in &setup_lines {
1110 let _ = writeln!(out, " {line}");
1111 }
1112 let _ = writeln!(
1113 out,
1114 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1115 );
1116 if expects_error {
1117 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1118 let _ = writeln!(out, " client.{function_name}({args_str})");
1119 let _ = writeln!(out, " }}");
1120 let _ = writeln!(out, " client.close()");
1121 let _ = writeln!(out, " }}");
1122 return;
1123 }
1124 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
1125 if !collect_snippet.is_empty() {
1126 let _ = writeln!(out, " {collect_snippet}");
1127 }
1128 for assertion in &fixture.assertions {
1129 render_assertion(
1130 out,
1131 assertion,
1132 result_var,
1133 class_name,
1134 field_resolver,
1135 result_is_simple,
1136 result_is_option,
1137 enum_fields,
1138 &e2e_config.fields_c_types,
1139 );
1140 }
1141 let _ = writeln!(out, " client.close()");
1142 let _ = writeln!(out, " }}");
1143 return;
1144 }
1145
1146 if expects_error {
1148 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1151 for line in &setup_lines {
1152 let _ = writeln!(out, " {line}");
1153 }
1154 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
1155 let _ = writeln!(out, " }}");
1156 let _ = writeln!(out, " }}");
1157 return;
1158 }
1159
1160 for line in &setup_lines {
1161 let _ = writeln!(out, " {line}");
1162 }
1163
1164 let _ = writeln!(
1165 out,
1166 " val {result_var} = {class_name}.{function_name}({args_str})"
1167 );
1168
1169 if !collect_snippet.is_empty() {
1170 let _ = writeln!(out, " {collect_snippet}");
1171 }
1172
1173 for assertion in &fixture.assertions {
1174 render_assertion(
1175 out,
1176 assertion,
1177 result_var,
1178 class_name,
1179 field_resolver,
1180 result_is_simple,
1181 result_is_option,
1182 enum_fields,
1183 &e2e_config.fields_c_types,
1184 );
1185 }
1186
1187 let _ = writeln!(out, " }}");
1188}
1189
1190fn build_args_and_setup(
1194 fixture: &Fixture,
1195 input: &serde_json::Value,
1196 args: &[crate::config::ArgMapping],
1197 class_name: &str,
1198 options_type: Option<&str>,
1199 fixture_id: &str,
1200) -> (Vec<String>, String) {
1201 if args.is_empty() {
1202 return (Vec::new(), String::new());
1203 }
1204
1205 let mut setup_lines: Vec<String> = Vec::new();
1206 let mut parts: Vec<String> = Vec::new();
1207
1208 for arg in args {
1209 if arg.arg_type == "mock_url" {
1210 if fixture.has_host_root_route() {
1211 setup_lines.push(format!(
1212 "val {} = System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\")",
1213 arg.name,
1214 ));
1215 } else {
1216 setup_lines.push(format!(
1217 "val {} = System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\"",
1218 arg.name,
1219 ));
1220 }
1221 parts.push(arg.name.clone());
1222 continue;
1223 }
1224
1225 if arg.arg_type == "handle" {
1226 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1227 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1228 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1229 if config_value.is_null()
1230 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1231 {
1232 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1233 } else {
1234 let json_str = serde_json::to_string(config_value).unwrap_or_default();
1235 let name = &arg.name;
1236 setup_lines.push(format!(
1237 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1238 escape_kotlin(&json_str),
1239 ));
1240 setup_lines.push(format!(
1241 "val {} = {class_name}.{constructor_name}({name}Config)",
1242 arg.name,
1243 name = name,
1244 ));
1245 }
1246 parts.push(arg.name.clone());
1247 continue;
1248 }
1249
1250 let val_resolved = super::resolve_field(input, &arg.field);
1252 let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1253 None
1254 } else {
1255 Some(val_resolved)
1256 };
1257 match val {
1258 None | Some(serde_json::Value::Null) if arg.optional => {
1259 if arg.arg_type == "json_object" {
1264 if let Some(opts_type) = options_type {
1265 parts.push(format!("{opts_type}.builder().build()"));
1266 } else {
1267 parts.push("null".to_string());
1268 }
1269 } else {
1270 parts.push("null".to_string());
1271 }
1272 }
1273 None | Some(serde_json::Value::Null) => {
1274 let default_val = match arg.arg_type.as_str() {
1275 "string" => "\"\"".to_string(),
1276 "int" | "integer" => "0".to_string(),
1277 "float" | "number" => "0.0".to_string(),
1278 "bool" | "boolean" => "false".to_string(),
1279 _ => "null".to_string(),
1280 };
1281 parts.push(default_val);
1282 }
1283 Some(v) => {
1284 if arg.arg_type == "json_object" && v.is_array() {
1289 if let Some(elem) = &arg.element_type {
1290 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1291 parts.push(emit_kotlin_batch_item_array(v, elem));
1292 continue;
1293 }
1294 let items: Vec<String> = v
1296 .as_array()
1297 .map(|arr| arr.iter().map(json_to_kotlin).collect())
1298 .unwrap_or_default();
1299 parts.push(format!("listOf({})", items.join(", ")));
1300 continue;
1301 }
1302 }
1303 if arg.arg_type == "json_object" && options_type.is_some() {
1305 parts.push(arg.name.clone());
1306 continue;
1307 }
1308 if arg.arg_type == "bytes" {
1312 let val = json_to_kotlin(v);
1313 parts.push(format!(
1314 "java.nio.file.Files.readAllBytes(java.nio.file.Path.of({val}))"
1315 ));
1316 continue;
1317 }
1318 if arg.arg_type == "file_path" {
1322 let val = json_to_kotlin(v);
1323 parts.push(format!("java.nio.file.Path.of({val})"));
1324 continue;
1325 }
1326 parts.push(json_to_kotlin(v));
1327 }
1328 }
1329 }
1330
1331 (setup_lines, parts.join(", "))
1332}
1333
1334fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1338 let Some(items) = arr.as_array() else {
1339 return "emptyList()".to_string();
1340 };
1341 let parts: Vec<String> = items
1342 .iter()
1343 .filter_map(|item| {
1344 let obj = item.as_object()?;
1345 match elem_type {
1346 "BatchBytesItem" => {
1347 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1348 let content_code = obj
1349 .get("content")
1350 .and_then(|v| v.as_array())
1351 .map(|arr| {
1352 let bytes: Vec<String> =
1353 arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1354 format!("byteArrayOf({})", bytes.join(", "))
1355 })
1356 .unwrap_or_else(|| "byteArrayOf()".to_string());
1357 Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1358 }
1359 "BatchFileItem" => {
1360 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1361 Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1362 }
1363 _ => None,
1364 }
1365 })
1366 .collect();
1367 format!("listOf({})", parts.join(", "))
1368}
1369
1370#[allow(clippy::too_many_arguments)]
1371fn render_assertion(
1372 out: &mut String,
1373 assertion: &Assertion,
1374 result_var: &str,
1375 _class_name: &str,
1376 field_resolver: &FieldResolver,
1377 result_is_simple: bool,
1378 result_is_option: bool,
1379 enum_fields: &HashSet<String>,
1380 fields_c_types: &std::collections::HashMap<String, String>,
1381) {
1382 if let Some(f) = &assertion.field {
1385 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1386 if let Some(expr) =
1387 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "kotlin", "chunks")
1388 {
1389 let line = match assertion.assertion_type.as_str() {
1390 "count_min" => {
1391 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1392 format!(" assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1393 } else {
1394 String::new()
1395 }
1396 }
1397 "count_equals" => {
1398 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1399 format!(
1400 " assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1401 )
1402 } else {
1403 String::new()
1404 }
1405 }
1406 "equals" => {
1407 if let Some(serde_json::Value::String(s)) = &assertion.value {
1408 let escaped = escape_kotlin(s);
1409 format!(" assertEquals(\"{escaped}\", {expr})\n")
1410 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1411 format!(" assertEquals({b}, {expr})\n")
1412 } else {
1413 String::new()
1414 }
1415 }
1416 "not_empty" => {
1417 format!(" assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1418 }
1419 "is_empty" => {
1420 format!(" assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1421 }
1422 "is_true" => {
1423 format!(" assertTrue({expr}, \"expected true\")\n")
1424 }
1425 "is_false" => {
1426 format!(" assertFalse({expr}, \"expected false\")\n")
1427 }
1428 "greater_than" => {
1429 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1430 format!(" assertTrue({expr} > {n}, \"expected > {n}\")\n")
1431 } else {
1432 String::new()
1433 }
1434 }
1435 "contains" => {
1436 if let Some(serde_json::Value::String(s)) = &assertion.value {
1437 let escaped = escape_kotlin(s);
1438 format!(
1439 " assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1440 )
1441 } else {
1442 String::new()
1443 }
1444 }
1445 _ => format!(
1446 " // streaming field '{f}': assertion type '{}' not rendered\n",
1447 assertion.assertion_type
1448 ),
1449 };
1450 if !line.is_empty() {
1451 out.push_str(&line);
1452 }
1453 }
1454 return;
1455 }
1456 }
1457
1458 if let Some(f) = &assertion.field {
1460 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1461 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1462 return;
1463 }
1464 }
1465
1466 let field_is_enum = assertion
1468 .field
1469 .as_deref()
1470 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1471
1472 let field_expr = if result_is_simple {
1474 result_var.to_string()
1475 } else {
1476 match &assertion.field {
1477 Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1478 _ => result_var.to_string(),
1479 }
1480 };
1481
1482 let field_is_optional = !result_is_simple
1492 && (field_expr.contains("?.")
1493 || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1494 let resolved = field_resolver.resolve(f);
1495 if field_resolver.has_map_access(f) {
1496 return false;
1497 }
1498 if field_resolver.is_optional(resolved) {
1500 return true;
1501 }
1502 let mut prefix = String::new();
1505 for part in resolved.split('.') {
1506 let key = part.split('[').next().unwrap_or(part);
1508 if !prefix.is_empty() {
1509 prefix.push('.');
1510 }
1511 prefix.push_str(key);
1512 if field_resolver.is_optional(&prefix) {
1513 return true;
1514 }
1515 }
1516 false
1517 }));
1518
1519 let string_field_expr = if field_is_optional {
1525 format!("{field_expr}.orEmpty()")
1526 } else {
1527 field_expr.clone()
1528 };
1529
1530 let nonnull_field_expr = if field_is_optional {
1533 format!("{field_expr}!!")
1534 } else {
1535 field_expr.clone()
1536 };
1537
1538 let string_expr = match (field_is_enum, field_is_optional) {
1544 (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1545 (true, false) => format!("{field_expr}.getValue()"),
1546 (false, _) => string_field_expr.clone(),
1547 };
1548
1549 let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1553 let resolved = field_resolver.resolve(f);
1554 matches!(
1555 fields_c_types.get(resolved).map(String::as_str),
1556 Some("uint64_t") | Some("int64_t")
1557 )
1558 });
1559
1560 match assertion.assertion_type.as_str() {
1561 "equals" => {
1562 if let Some(expected) = &assertion.value {
1563 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1567 format!("{}L", expected)
1568 } else {
1569 json_to_kotlin(expected)
1570 };
1571 if expected.is_string() {
1572 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1573 } else {
1574 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1575 }
1576 }
1577 }
1578 "contains" => {
1579 if let Some(expected) = &assertion.value {
1580 let kotlin_val = json_to_kotlin(expected);
1581 let _ = writeln!(
1582 out,
1583 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1584 );
1585 }
1586 }
1587 "contains_all" => {
1588 if let Some(values) = &assertion.values {
1589 for val in values {
1590 let kotlin_val = json_to_kotlin(val);
1591 let _ = writeln!(
1592 out,
1593 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1594 );
1595 }
1596 }
1597 }
1598 "not_contains" => {
1599 if let Some(expected) = &assertion.value {
1600 let kotlin_val = json_to_kotlin(expected);
1601 let _ = writeln!(
1602 out,
1603 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1604 );
1605 }
1606 }
1607 "not_empty" => {
1608 let bare_result_is_option =
1618 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1619 if bare_result_is_option {
1620 let _ = writeln!(
1621 out,
1622 " assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
1623 );
1624 } else if field_is_optional {
1625 let _ = writeln!(
1626 out,
1627 " assertTrue({field_expr} != null, \"expected non-empty value\")"
1628 );
1629 } else {
1630 let _ = writeln!(
1631 out,
1632 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1633 );
1634 }
1635 }
1636 "is_empty" => {
1637 let bare_result_is_option =
1638 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1639 if bare_result_is_option {
1640 let _ = writeln!(
1641 out,
1642 " assertTrue({field_expr}.isEmpty, \"expected empty value\")"
1643 );
1644 } else if field_is_optional {
1645 let _ = writeln!(
1646 out,
1647 " assertTrue({field_expr} == null, \"expected empty value\")"
1648 );
1649 } else {
1650 let _ = writeln!(
1651 out,
1652 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1653 );
1654 }
1655 }
1656 "contains_any" => {
1657 if let Some(values) = &assertion.values {
1658 let checks: Vec<String> = values
1659 .iter()
1660 .map(|v| {
1661 let kotlin_val = json_to_kotlin(v);
1662 format!("{string_expr}.contains({kotlin_val})")
1663 })
1664 .collect();
1665 let joined = checks.join(" || ");
1666 let _ = writeln!(
1667 out,
1668 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1669 );
1670 }
1671 }
1672 "greater_than" => {
1673 if let Some(val) = &assertion.value {
1674 let kotlin_val = json_to_kotlin(val);
1675 let _ = writeln!(
1676 out,
1677 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
1678 );
1679 }
1680 }
1681 "less_than" => {
1682 if let Some(val) = &assertion.value {
1683 let kotlin_val = json_to_kotlin(val);
1684 let _ = writeln!(
1685 out,
1686 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
1687 );
1688 }
1689 }
1690 "greater_than_or_equal" => {
1691 if let Some(val) = &assertion.value {
1692 let kotlin_val = json_to_kotlin(val);
1693 let _ = writeln!(
1694 out,
1695 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
1696 );
1697 }
1698 }
1699 "less_than_or_equal" => {
1700 if let Some(val) = &assertion.value {
1701 let kotlin_val = json_to_kotlin(val);
1702 let _ = writeln!(
1703 out,
1704 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
1705 );
1706 }
1707 }
1708 "starts_with" => {
1709 if let Some(expected) = &assertion.value {
1710 let kotlin_val = json_to_kotlin(expected);
1711 let _ = writeln!(
1712 out,
1713 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1714 );
1715 }
1716 }
1717 "ends_with" => {
1718 if let Some(expected) = &assertion.value {
1719 let kotlin_val = json_to_kotlin(expected);
1720 let _ = writeln!(
1721 out,
1722 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1723 );
1724 }
1725 }
1726 "min_length" => {
1727 if let Some(val) = &assertion.value {
1728 if let Some(n) = val.as_u64() {
1729 let _ = writeln!(
1730 out,
1731 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1732 );
1733 }
1734 }
1735 }
1736 "max_length" => {
1737 if let Some(val) = &assertion.value {
1738 if let Some(n) = val.as_u64() {
1739 let _ = writeln!(
1740 out,
1741 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1742 );
1743 }
1744 }
1745 }
1746 "count_min" => {
1747 if let Some(val) = &assertion.value {
1748 if let Some(n) = val.as_u64() {
1749 let _ = writeln!(
1750 out,
1751 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1752 );
1753 }
1754 }
1755 }
1756 "count_equals" => {
1757 if let Some(val) = &assertion.value {
1758 if let Some(n) = val.as_u64() {
1759 let _ = writeln!(
1760 out,
1761 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1762 );
1763 }
1764 }
1765 }
1766 "is_true" => {
1767 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
1768 }
1769 "is_false" => {
1770 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
1771 }
1772 "matches_regex" => {
1773 if let Some(expected) = &assertion.value {
1774 let kotlin_val = json_to_kotlin(expected);
1775 let _ = writeln!(
1776 out,
1777 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1778 );
1779 }
1780 }
1781 "not_error" => {
1782 }
1784 "error" => {
1785 }
1787 "method_result" => {
1788 let _ = writeln!(
1790 out,
1791 " // method_result assertions not yet implemented for Kotlin"
1792 );
1793 }
1794 other => {
1795 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1796 }
1797 }
1798}
1799
1800fn json_to_kotlin(value: &serde_json::Value) -> String {
1802 match value {
1803 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1804 serde_json::Value::Bool(b) => b.to_string(),
1805 serde_json::Value::Number(n) => {
1806 if n.is_f64() {
1807 let s = n.to_string();
1810 if s.contains('.') || s.contains('e') || s.contains('E') {
1811 s
1812 } else {
1813 format!("{s}.0")
1814 }
1815 } else {
1816 n.to_string()
1817 }
1818 }
1819 serde_json::Value::Null => "null".to_string(),
1820 serde_json::Value::Array(arr) => {
1821 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1822 format!("listOf({})", items.join(", "))
1823 }
1824 serde_json::Value::Object(_) => {
1825 let json_str = serde_json::to_string(value).unwrap_or_default();
1826 format!("\"{}\"", escape_kotlin(&json_str))
1827 }
1828 }
1829}
1830
1831#[cfg(test)]
1832mod tests {
1833 use super::*;
1834 use std::collections::HashMap;
1835
1836 fn make_resolver_for_finish_reason() -> FieldResolver {
1837 let mut optional = HashSet::new();
1841 optional.insert("choices.finish_reason".to_string());
1842 let mut arrays = HashSet::new();
1843 arrays.insert("choices".to_string());
1844 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
1845 }
1846
1847 #[test]
1851 fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
1852 let resolver = make_resolver_for_finish_reason();
1853 let mut enum_fields = HashSet::new();
1854 enum_fields.insert("choices.finish_reason".to_string());
1855 let assertion = Assertion {
1856 assertion_type: "equals".to_string(),
1857 field: Some("choices.finish_reason".to_string()),
1858 value: Some(serde_json::Value::String("stop".to_string())),
1859 values: None,
1860 method: None,
1861 check: None,
1862 args: None,
1863 return_type: None,
1864 };
1865 let mut out = String::new();
1866 render_assertion(
1867 &mut out,
1868 &assertion,
1869 "result",
1870 "",
1871 &resolver,
1872 false,
1873 false,
1874 &enum_fields,
1875 &HashMap::new(),
1876 );
1877 assert!(
1878 out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
1879 "expected enum-optional safe-call pattern, got: {out}"
1880 );
1881 assert!(
1882 !out.contains(".finishReason().orEmpty().getValue()"),
1883 "must not emit .orEmpty().getValue() on a nullable enum: {out}"
1884 );
1885 }
1886
1887 #[test]
1890 fn assertion_enum_non_optional_uses_plain_get_value() {
1891 let mut arrays = HashSet::new();
1892 arrays.insert("choices".to_string());
1893 let resolver = FieldResolver::new(
1894 &HashMap::new(),
1895 &HashSet::new(),
1896 &HashSet::new(),
1897 &arrays,
1898 &HashSet::new(),
1899 );
1900 let mut enum_fields = HashSet::new();
1901 enum_fields.insert("choices.finish_reason".to_string());
1902 let assertion = Assertion {
1903 assertion_type: "equals".to_string(),
1904 field: Some("choices.finish_reason".to_string()),
1905 value: Some(serde_json::Value::String("stop".to_string())),
1906 values: None,
1907 method: None,
1908 check: None,
1909 args: None,
1910 return_type: None,
1911 };
1912 let mut out = String::new();
1913 render_assertion(
1914 &mut out,
1915 &assertion,
1916 "result",
1917 "",
1918 &resolver,
1919 false,
1920 false,
1921 &enum_fields,
1922 &HashMap::new(),
1923 );
1924 assert!(
1925 out.contains("result.choices().first().finishReason().getValue().trim()"),
1926 "expected plain .getValue() for non-optional enum, got: {out}"
1927 );
1928 }
1929
1930 #[test]
1936 fn per_call_enum_field_override_routes_through_get_value() {
1937 let resolver = FieldResolver::new(
1939 &HashMap::new(),
1940 &HashSet::new(),
1941 &HashSet::new(),
1942 &HashSet::new(),
1943 &HashSet::new(),
1944 );
1945 let global_enum_fields: HashSet<String> = HashSet::new();
1947 let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
1949 per_call_enum_fields.insert("status".to_string());
1950
1951 let assertion = Assertion {
1952 assertion_type: "equals".to_string(),
1953 field: Some("status".to_string()),
1954 value: Some(serde_json::Value::String("validating".to_string())),
1955 values: None,
1956 method: None,
1957 check: None,
1958 args: None,
1959 return_type: None,
1960 };
1961
1962 let mut out_no_merge = String::new();
1964 render_assertion(
1965 &mut out_no_merge,
1966 &assertion,
1967 "result",
1968 "",
1969 &resolver,
1970 false,
1971 false,
1972 &global_enum_fields,
1973 &HashMap::new(),
1974 );
1975 assert!(
1976 !out_no_merge.contains(".getValue()"),
1977 "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
1978 );
1979
1980 let mut out_merged = String::new();
1982 render_assertion(
1983 &mut out_merged,
1984 &assertion,
1985 "result",
1986 "",
1987 &resolver,
1988 false,
1989 false,
1990 &per_call_enum_fields,
1991 &HashMap::new(),
1992 );
1993 assert!(
1994 out_merged.contains(".getValue()"),
1995 "merged per-call set must emit .getValue() for status: {out_merged}"
1996 );
1997 }
1998
1999 #[test]
2004 fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2005 use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2006
2007 let batch_object_def = TypeDef {
2009 name: "BatchObject".to_string(),
2010 rust_path: "liter_llm::BatchObject".to_string(),
2011 original_rust_path: String::new(),
2012 fields: vec![
2013 FieldDef {
2014 name: "id".to_string(),
2015 ty: TypeRef::String,
2016 optional: false,
2017 default: None,
2018 doc: String::new(),
2019 sanitized: false,
2020 is_boxed: false,
2021 type_rust_path: None,
2022 cfg: None,
2023 typed_default: None,
2024 core_wrapper: CoreWrapper::None,
2025 vec_inner_core_wrapper: CoreWrapper::None,
2026 newtype_wrapper: None,
2027 serde_rename: None,
2028 serde_flatten: false,
2029 },
2030 FieldDef {
2031 name: "status".to_string(),
2032 ty: TypeRef::Named("BatchStatus".to_string()),
2033 optional: false,
2034 default: None,
2035 doc: String::new(),
2036 sanitized: false,
2037 is_boxed: false,
2038 type_rust_path: None,
2039 cfg: None,
2040 typed_default: None,
2041 core_wrapper: CoreWrapper::None,
2042 vec_inner_core_wrapper: CoreWrapper::None,
2043 newtype_wrapper: None,
2044 serde_rename: None,
2045 serde_flatten: false,
2046 },
2047 ],
2048 methods: vec![],
2049 is_opaque: false,
2050 is_clone: true,
2051 is_copy: false,
2052 doc: String::new(),
2053 cfg: None,
2054 is_trait: false,
2055 has_default: false,
2056 has_stripped_cfg_fields: false,
2057 is_return_type: true,
2058 serde_rename_all: None,
2059 has_serde: true,
2060 super_traits: vec![],
2061 };
2062
2063 let type_defs = [batch_object_def];
2065 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2066
2067 let status_ty = TypeRef::Named("BatchStatus".to_string());
2069 assert!(
2070 is_enum_typed(&status_ty, &struct_names),
2071 "BatchStatus (not a known struct) should be detected as enum-typed"
2072 );
2073 let id_ty = TypeRef::String;
2074 assert!(
2075 !is_enum_typed(&id_ty, &struct_names),
2076 "String field should NOT be detected as enum-typed"
2077 );
2078
2079 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2081 .iter()
2082 .filter_map(|td| {
2083 let enum_field_names: HashSet<String> = td
2084 .fields
2085 .iter()
2086 .filter(|field| is_enum_typed(&field.ty, &struct_names))
2087 .map(|field| field.name.clone())
2088 .collect();
2089 if enum_field_names.is_empty() {
2090 None
2091 } else {
2092 Some((td.name.clone(), enum_field_names))
2093 }
2094 })
2095 .collect();
2096
2097 let batch_enum_fields = type_enum_fields
2098 .get("BatchObject")
2099 .expect("BatchObject should have enum fields");
2100 assert!(
2101 batch_enum_fields.contains("status"),
2102 "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2103 );
2104 assert!(
2105 !batch_enum_fields.contains("id"),
2106 "BatchObject.id (String) must not be in enum fields"
2107 );
2108
2109 let resolver = FieldResolver::new(
2111 &HashMap::new(),
2112 &HashSet::new(),
2113 &HashSet::new(),
2114 &HashSet::new(),
2115 &HashSet::new(),
2116 );
2117 let assertion = Assertion {
2118 assertion_type: "equals".to_string(),
2119 field: Some("status".to_string()),
2120 value: Some(serde_json::Value::String("validating".to_string())),
2121 values: None,
2122 method: None,
2123 check: None,
2124 args: None,
2125 return_type: None,
2126 };
2127 let mut out = String::new();
2128 render_assertion(
2129 &mut out,
2130 &assertion,
2131 "result",
2132 "",
2133 &resolver,
2134 false,
2135 false,
2136 batch_enum_fields,
2137 &HashMap::new(),
2138 );
2139 assert!(
2140 out.contains(".getValue()"),
2141 "auto-detected enum field must route through .getValue(), got: {out}"
2142 );
2143 }
2144}