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!(
1113 "System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\") ?: \"\") + \"/fixtures/{fixture_id}\")"
1114 );
1115 for line in &setup_lines {
1116 let _ = writeln!(out, " {line}");
1117 }
1118 let _ = writeln!(
1119 out,
1120 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1121 );
1122 if expects_error {
1123 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1124 let _ = writeln!(out, " client.{function_name}({args_str})");
1125 let _ = writeln!(out, " }}");
1126 let _ = writeln!(out, " client.close()");
1127 let _ = writeln!(out, " }}");
1128 return;
1129 }
1130 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
1131 if !collect_snippet.is_empty() {
1132 let _ = writeln!(out, " {collect_snippet}");
1133 }
1134 for assertion in &fixture.assertions {
1135 render_assertion(
1136 out,
1137 assertion,
1138 result_var,
1139 class_name,
1140 field_resolver,
1141 result_is_simple,
1142 result_is_option,
1143 enum_fields,
1144 &e2e_config.fields_c_types,
1145 is_streaming,
1146 );
1147 }
1148 let _ = writeln!(out, " client.close()");
1149 let _ = writeln!(out, " }}");
1150 return;
1151 }
1152
1153 if expects_error {
1155 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1158 for line in &setup_lines {
1159 let _ = writeln!(out, " {line}");
1160 }
1161 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
1162 let _ = writeln!(out, " }}");
1163 let _ = writeln!(out, " }}");
1164 return;
1165 }
1166
1167 for line in &setup_lines {
1168 let _ = writeln!(out, " {line}");
1169 }
1170
1171 let _ = writeln!(
1172 out,
1173 " val {result_var} = {class_name}.{function_name}({args_str})"
1174 );
1175
1176 if !collect_snippet.is_empty() {
1177 let _ = writeln!(out, " {collect_snippet}");
1178 }
1179
1180 for assertion in &fixture.assertions {
1181 render_assertion(
1182 out,
1183 assertion,
1184 result_var,
1185 class_name,
1186 field_resolver,
1187 result_is_simple,
1188 result_is_option,
1189 enum_fields,
1190 &e2e_config.fields_c_types,
1191 is_streaming,
1192 );
1193 }
1194
1195 let _ = writeln!(out, " }}");
1196}
1197
1198fn build_args_and_setup(
1202 fixture: &Fixture,
1203 input: &serde_json::Value,
1204 args: &[crate::config::ArgMapping],
1205 class_name: &str,
1206 options_type: Option<&str>,
1207 fixture_id: &str,
1208) -> (Vec<String>, String) {
1209 if args.is_empty() {
1210 return (Vec::new(), String::new());
1211 }
1212
1213 let mut setup_lines: Vec<String> = Vec::new();
1214 let mut parts: Vec<String> = Vec::new();
1215
1216 for arg in args {
1217 if arg.arg_type == "mock_url" {
1218 if fixture.has_host_root_route() {
1219 setup_lines.push(format!(
1220 "val {} = System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\")",
1221 arg.name,
1222 ));
1223 } else {
1224 setup_lines.push(format!(
1225 "val {} = System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\"",
1226 arg.name,
1227 ));
1228 }
1229 parts.push(arg.name.clone());
1230 continue;
1231 }
1232
1233 if arg.arg_type == "handle" {
1234 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1235 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1236 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1237 if config_value.is_null()
1238 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1239 {
1240 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1241 } else {
1242 let json_str = serde_json::to_string(config_value).unwrap_or_default();
1243 let name = &arg.name;
1244 setup_lines.push(format!(
1245 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1246 escape_kotlin(&json_str),
1247 ));
1248 setup_lines.push(format!(
1249 "val {} = {class_name}.{constructor_name}({name}Config)",
1250 arg.name,
1251 name = name,
1252 ));
1253 }
1254 parts.push(arg.name.clone());
1255 continue;
1256 }
1257
1258 let val_resolved = super::resolve_field(input, &arg.field);
1260 let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1261 None
1262 } else {
1263 Some(val_resolved)
1264 };
1265 match val {
1266 None | Some(serde_json::Value::Null) if arg.optional => {
1267 if arg.arg_type == "json_object" {
1272 if let Some(opts_type) = options_type {
1273 parts.push(format!("{opts_type}.builder().build()"));
1274 } else {
1275 parts.push("null".to_string());
1276 }
1277 } else {
1278 parts.push("null".to_string());
1279 }
1280 }
1281 None | Some(serde_json::Value::Null) => {
1282 let default_val = match arg.arg_type.as_str() {
1283 "string" => "\"\"".to_string(),
1284 "int" | "integer" => "0".to_string(),
1285 "float" | "number" => "0.0".to_string(),
1286 "bool" | "boolean" => "false".to_string(),
1287 _ => "null".to_string(),
1288 };
1289 parts.push(default_val);
1290 }
1291 Some(v) => {
1292 if arg.arg_type == "json_object" && v.is_array() {
1297 if let Some(elem) = &arg.element_type {
1298 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1299 parts.push(emit_kotlin_batch_item_array(v, elem));
1300 continue;
1301 }
1302 let items: Vec<String> = v
1304 .as_array()
1305 .map(|arr| arr.iter().map(json_to_kotlin).collect())
1306 .unwrap_or_default();
1307 parts.push(format!("listOf({})", items.join(", ")));
1308 continue;
1309 }
1310 }
1311 if arg.arg_type == "json_object" && options_type.is_some() {
1313 parts.push(arg.name.clone());
1314 continue;
1315 }
1316 if arg.arg_type == "bytes" {
1320 let val = json_to_kotlin(v);
1321 parts.push(format!(
1322 "java.nio.file.Files.readAllBytes(java.nio.file.Path.of({val}))"
1323 ));
1324 continue;
1325 }
1326 if arg.arg_type == "file_path" {
1330 let val = json_to_kotlin(v);
1331 parts.push(format!("java.nio.file.Path.of({val})"));
1332 continue;
1333 }
1334 parts.push(json_to_kotlin(v));
1335 }
1336 }
1337 }
1338
1339 (setup_lines, parts.join(", "))
1340}
1341
1342fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1346 let Some(items) = arr.as_array() else {
1347 return "emptyList()".to_string();
1348 };
1349 let parts: Vec<String> = items
1350 .iter()
1351 .filter_map(|item| {
1352 let obj = item.as_object()?;
1353 match elem_type {
1354 "BatchBytesItem" => {
1355 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1356 let content_code = obj
1357 .get("content")
1358 .and_then(|v| v.as_array())
1359 .map(|arr| {
1360 let bytes: Vec<String> =
1361 arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1362 format!("byteArrayOf({})", bytes.join(", "))
1363 })
1364 .unwrap_or_else(|| "byteArrayOf()".to_string());
1365 Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1366 }
1367 "BatchFileItem" => {
1368 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1369 Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1370 }
1371 _ => None,
1372 }
1373 })
1374 .collect();
1375 format!("listOf({})", parts.join(", "))
1376}
1377
1378#[allow(clippy::too_many_arguments)]
1379fn render_assertion(
1380 out: &mut String,
1381 assertion: &Assertion,
1382 result_var: &str,
1383 _class_name: &str,
1384 field_resolver: &FieldResolver,
1385 result_is_simple: bool,
1386 result_is_option: bool,
1387 enum_fields: &HashSet<String>,
1388 fields_c_types: &std::collections::HashMap<String, String>,
1389 is_streaming: bool,
1390) {
1391 if is_streaming {
1396 if let Some(f) = &assertion.field {
1397 if f == "usage" || f.starts_with("usage.") {
1398 let base_expr =
1399 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor("usage", "kotlin", "chunks")
1400 .unwrap_or_else(|| "(if (chunks.isEmpty()) null else chunks.last().usage())".to_string());
1401
1402 let expr = if let Some(tail) = f.strip_prefix("usage.") {
1405 use heck::ToLowerCamelCase;
1406 tail.split('.')
1408 .fold(base_expr, |acc, seg| format!("{acc}?.{}()", seg.to_lower_camel_case()))
1409 } else {
1410 base_expr
1411 };
1412
1413 let field_is_long = fields_c_types
1415 .get(f.as_str())
1416 .is_some_and(|t| matches!(t.as_str(), "uint64_t" | "int64_t"));
1417
1418 let line = match assertion.assertion_type.as_str() {
1419 "equals" => {
1420 if let Some(expected) = &assertion.value {
1421 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1422 format!("{}L", expected)
1423 } else {
1424 json_to_kotlin(expected)
1425 };
1426 format!(" assertEquals({kotlin_val}, {expr}!!)\n")
1427 } else {
1428 String::new()
1429 }
1430 }
1431 _ => String::new(),
1432 };
1433 if !line.is_empty() {
1434 out.push_str(&line);
1435 }
1436 return;
1437 }
1438 }
1439 }
1440
1441 if let Some(f) = &assertion.field {
1444 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1445 if let Some(expr) =
1446 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "kotlin", "chunks")
1447 {
1448 let line = match assertion.assertion_type.as_str() {
1449 "count_min" => {
1450 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1451 format!(" assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1452 } else {
1453 String::new()
1454 }
1455 }
1456 "count_equals" => {
1457 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1458 format!(
1459 " assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1460 )
1461 } else {
1462 String::new()
1463 }
1464 }
1465 "equals" => {
1466 if let Some(serde_json::Value::String(s)) = &assertion.value {
1467 let escaped = escape_kotlin(s);
1468 format!(" assertEquals(\"{escaped}\", {expr})\n")
1469 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1470 format!(" assertEquals({b}, {expr})\n")
1471 } else {
1472 String::new()
1473 }
1474 }
1475 "not_empty" => {
1476 format!(" assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1477 }
1478 "is_empty" => {
1479 format!(" assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1480 }
1481 "is_true" => {
1482 format!(" assertTrue({expr}, \"expected true\")\n")
1483 }
1484 "is_false" => {
1485 format!(" assertFalse({expr}, \"expected false\")\n")
1486 }
1487 "greater_than" => {
1488 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1489 format!(" assertTrue({expr} > {n}, \"expected > {n}\")\n")
1490 } else {
1491 String::new()
1492 }
1493 }
1494 "contains" => {
1495 if let Some(serde_json::Value::String(s)) = &assertion.value {
1496 let escaped = escape_kotlin(s);
1497 format!(
1498 " assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1499 )
1500 } else {
1501 String::new()
1502 }
1503 }
1504 _ => format!(
1505 " // streaming field '{f}': assertion type '{}' not rendered\n",
1506 assertion.assertion_type
1507 ),
1508 };
1509 if !line.is_empty() {
1510 out.push_str(&line);
1511 }
1512 }
1513 return;
1514 }
1515 }
1516
1517 if let Some(f) = &assertion.field {
1519 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1520 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1521 return;
1522 }
1523 }
1524
1525 let field_is_enum = assertion
1527 .field
1528 .as_deref()
1529 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1530
1531 let field_expr = if result_is_simple {
1533 result_var.to_string()
1534 } else {
1535 match &assertion.field {
1536 Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1537 _ => result_var.to_string(),
1538 }
1539 };
1540
1541 let field_is_optional = !result_is_simple
1551 && (field_expr.contains("?.")
1552 || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1553 let resolved = field_resolver.resolve(f);
1554 if field_resolver.has_map_access(f) {
1555 return false;
1556 }
1557 if field_resolver.is_optional(resolved) {
1559 return true;
1560 }
1561 let mut prefix = String::new();
1564 for part in resolved.split('.') {
1565 let key = part.split('[').next().unwrap_or(part);
1567 if !prefix.is_empty() {
1568 prefix.push('.');
1569 }
1570 prefix.push_str(key);
1571 if field_resolver.is_optional(&prefix) {
1572 return true;
1573 }
1574 }
1575 false
1576 }));
1577
1578 let string_field_expr = if field_is_optional {
1584 format!("{field_expr}.orEmpty()")
1585 } else {
1586 field_expr.clone()
1587 };
1588
1589 let nonnull_field_expr = if field_is_optional {
1592 format!("{field_expr}!!")
1593 } else {
1594 field_expr.clone()
1595 };
1596
1597 let string_expr = match (field_is_enum, field_is_optional) {
1603 (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1604 (true, false) => format!("{field_expr}.getValue()"),
1605 (false, _) => string_field_expr.clone(),
1606 };
1607
1608 let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1612 let resolved = field_resolver.resolve(f);
1613 matches!(
1614 fields_c_types.get(resolved).map(String::as_str),
1615 Some("uint64_t") | Some("int64_t")
1616 )
1617 });
1618
1619 match assertion.assertion_type.as_str() {
1620 "equals" => {
1621 if let Some(expected) = &assertion.value {
1622 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1626 format!("{}L", expected)
1627 } else {
1628 json_to_kotlin(expected)
1629 };
1630 if expected.is_string() {
1631 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1632 } else {
1633 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1634 }
1635 }
1636 }
1637 "contains" => {
1638 if let Some(expected) = &assertion.value {
1639 let kotlin_val = json_to_kotlin(expected);
1640 let _ = writeln!(
1641 out,
1642 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1643 );
1644 }
1645 }
1646 "contains_all" => {
1647 if let Some(values) = &assertion.values {
1648 for val in values {
1649 let kotlin_val = json_to_kotlin(val);
1650 let _ = writeln!(
1651 out,
1652 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1653 );
1654 }
1655 }
1656 }
1657 "not_contains" => {
1658 if let Some(expected) = &assertion.value {
1659 let kotlin_val = json_to_kotlin(expected);
1660 let _ = writeln!(
1661 out,
1662 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1663 );
1664 }
1665 }
1666 "not_empty" => {
1667 let bare_result_is_option =
1677 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1678 if bare_result_is_option {
1679 let _ = writeln!(
1680 out,
1681 " assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
1682 );
1683 } else if field_is_optional {
1684 let _ = writeln!(
1685 out,
1686 " assertTrue({field_expr} != null, \"expected non-empty value\")"
1687 );
1688 } else {
1689 let _ = writeln!(
1690 out,
1691 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1692 );
1693 }
1694 }
1695 "is_empty" => {
1696 let bare_result_is_option =
1697 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1698 if bare_result_is_option {
1699 let _ = writeln!(
1700 out,
1701 " assertTrue({field_expr}.isEmpty, \"expected empty value\")"
1702 );
1703 } else if field_is_optional {
1704 let _ = writeln!(
1705 out,
1706 " assertTrue({field_expr} == null, \"expected empty value\")"
1707 );
1708 } else {
1709 let _ = writeln!(
1710 out,
1711 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1712 );
1713 }
1714 }
1715 "contains_any" => {
1716 if let Some(values) = &assertion.values {
1717 let checks: Vec<String> = values
1718 .iter()
1719 .map(|v| {
1720 let kotlin_val = json_to_kotlin(v);
1721 format!("{string_expr}.contains({kotlin_val})")
1722 })
1723 .collect();
1724 let joined = checks.join(" || ");
1725 let _ = writeln!(
1726 out,
1727 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1728 );
1729 }
1730 }
1731 "greater_than" => {
1732 if let Some(val) = &assertion.value {
1733 let kotlin_val = json_to_kotlin(val);
1734 let _ = writeln!(
1735 out,
1736 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
1737 );
1738 }
1739 }
1740 "less_than" => {
1741 if let Some(val) = &assertion.value {
1742 let kotlin_val = json_to_kotlin(val);
1743 let _ = writeln!(
1744 out,
1745 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
1746 );
1747 }
1748 }
1749 "greater_than_or_equal" => {
1750 if let Some(val) = &assertion.value {
1751 let kotlin_val = json_to_kotlin(val);
1752 let _ = writeln!(
1753 out,
1754 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
1755 );
1756 }
1757 }
1758 "less_than_or_equal" => {
1759 if let Some(val) = &assertion.value {
1760 let kotlin_val = json_to_kotlin(val);
1761 let _ = writeln!(
1762 out,
1763 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
1764 );
1765 }
1766 }
1767 "starts_with" => {
1768 if let Some(expected) = &assertion.value {
1769 let kotlin_val = json_to_kotlin(expected);
1770 let _ = writeln!(
1771 out,
1772 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1773 );
1774 }
1775 }
1776 "ends_with" => {
1777 if let Some(expected) = &assertion.value {
1778 let kotlin_val = json_to_kotlin(expected);
1779 let _ = writeln!(
1780 out,
1781 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1782 );
1783 }
1784 }
1785 "min_length" => {
1786 if let Some(val) = &assertion.value {
1787 if let Some(n) = val.as_u64() {
1788 let _ = writeln!(
1789 out,
1790 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1791 );
1792 }
1793 }
1794 }
1795 "max_length" => {
1796 if let Some(val) = &assertion.value {
1797 if let Some(n) = val.as_u64() {
1798 let _ = writeln!(
1799 out,
1800 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1801 );
1802 }
1803 }
1804 }
1805 "count_min" => {
1806 if let Some(val) = &assertion.value {
1807 if let Some(n) = val.as_u64() {
1808 let _ = writeln!(
1809 out,
1810 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1811 );
1812 }
1813 }
1814 }
1815 "count_equals" => {
1816 if let Some(val) = &assertion.value {
1817 if let Some(n) = val.as_u64() {
1818 let _ = writeln!(
1819 out,
1820 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1821 );
1822 }
1823 }
1824 }
1825 "is_true" => {
1826 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
1827 }
1828 "is_false" => {
1829 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
1830 }
1831 "matches_regex" => {
1832 if let Some(expected) = &assertion.value {
1833 let kotlin_val = json_to_kotlin(expected);
1834 let _ = writeln!(
1835 out,
1836 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1837 );
1838 }
1839 }
1840 "not_error" => {
1841 }
1843 "error" => {
1844 }
1846 "method_result" => {
1847 let _ = writeln!(
1849 out,
1850 " // method_result assertions not yet implemented for Kotlin"
1851 );
1852 }
1853 other => {
1854 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1855 }
1856 }
1857}
1858
1859fn json_to_kotlin(value: &serde_json::Value) -> String {
1861 match value {
1862 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1863 serde_json::Value::Bool(b) => b.to_string(),
1864 serde_json::Value::Number(n) => {
1865 if n.is_f64() {
1866 let s = n.to_string();
1869 if s.contains('.') || s.contains('e') || s.contains('E') {
1870 s
1871 } else {
1872 format!("{s}.0")
1873 }
1874 } else {
1875 n.to_string()
1876 }
1877 }
1878 serde_json::Value::Null => "null".to_string(),
1879 serde_json::Value::Array(arr) => {
1880 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1881 format!("listOf({})", items.join(", "))
1882 }
1883 serde_json::Value::Object(_) => {
1884 let json_str = serde_json::to_string(value).unwrap_or_default();
1885 format!("\"{}\"", escape_kotlin(&json_str))
1886 }
1887 }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892 use super::*;
1893 use std::collections::HashMap;
1894
1895 fn make_resolver_for_finish_reason() -> FieldResolver {
1896 let mut optional = HashSet::new();
1900 optional.insert("choices.finish_reason".to_string());
1901 let mut arrays = HashSet::new();
1902 arrays.insert("choices".to_string());
1903 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
1904 }
1905
1906 #[test]
1910 fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
1911 let resolver = make_resolver_for_finish_reason();
1912 let mut enum_fields = HashSet::new();
1913 enum_fields.insert("choices.finish_reason".to_string());
1914 let assertion = Assertion {
1915 assertion_type: "equals".to_string(),
1916 field: Some("choices.finish_reason".to_string()),
1917 value: Some(serde_json::Value::String("stop".to_string())),
1918 values: None,
1919 method: None,
1920 check: None,
1921 args: None,
1922 return_type: None,
1923 };
1924 let mut out = String::new();
1925 render_assertion(
1926 &mut out,
1927 &assertion,
1928 "result",
1929 "",
1930 &resolver,
1931 false,
1932 false,
1933 &enum_fields,
1934 &HashMap::new(),
1935 false,
1936 );
1937 assert!(
1938 out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
1939 "expected enum-optional safe-call pattern, got: {out}"
1940 );
1941 assert!(
1942 !out.contains(".finishReason().orEmpty().getValue()"),
1943 "must not emit .orEmpty().getValue() on a nullable enum: {out}"
1944 );
1945 }
1946
1947 #[test]
1950 fn assertion_enum_non_optional_uses_plain_get_value() {
1951 let mut arrays = HashSet::new();
1952 arrays.insert("choices".to_string());
1953 let resolver = FieldResolver::new(
1954 &HashMap::new(),
1955 &HashSet::new(),
1956 &HashSet::new(),
1957 &arrays,
1958 &HashSet::new(),
1959 );
1960 let mut enum_fields = HashSet::new();
1961 enum_fields.insert("choices.finish_reason".to_string());
1962 let assertion = Assertion {
1963 assertion_type: "equals".to_string(),
1964 field: Some("choices.finish_reason".to_string()),
1965 value: Some(serde_json::Value::String("stop".to_string())),
1966 values: None,
1967 method: None,
1968 check: None,
1969 args: None,
1970 return_type: None,
1971 };
1972 let mut out = String::new();
1973 render_assertion(
1974 &mut out,
1975 &assertion,
1976 "result",
1977 "",
1978 &resolver,
1979 false,
1980 false,
1981 &enum_fields,
1982 &HashMap::new(),
1983 false,
1984 );
1985 assert!(
1986 out.contains("result.choices().first().finishReason().getValue().trim()"),
1987 "expected plain .getValue() for non-optional enum, got: {out}"
1988 );
1989 }
1990
1991 #[test]
1997 fn per_call_enum_field_override_routes_through_get_value() {
1998 let resolver = FieldResolver::new(
2000 &HashMap::new(),
2001 &HashSet::new(),
2002 &HashSet::new(),
2003 &HashSet::new(),
2004 &HashSet::new(),
2005 );
2006 let global_enum_fields: HashSet<String> = HashSet::new();
2008 let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
2010 per_call_enum_fields.insert("status".to_string());
2011
2012 let assertion = Assertion {
2013 assertion_type: "equals".to_string(),
2014 field: Some("status".to_string()),
2015 value: Some(serde_json::Value::String("validating".to_string())),
2016 values: None,
2017 method: None,
2018 check: None,
2019 args: None,
2020 return_type: None,
2021 };
2022
2023 let mut out_no_merge = String::new();
2025 render_assertion(
2026 &mut out_no_merge,
2027 &assertion,
2028 "result",
2029 "",
2030 &resolver,
2031 false,
2032 false,
2033 &global_enum_fields,
2034 &HashMap::new(),
2035 false,
2036 );
2037 assert!(
2038 !out_no_merge.contains(".getValue()"),
2039 "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
2040 );
2041
2042 let mut out_merged = String::new();
2044 render_assertion(
2045 &mut out_merged,
2046 &assertion,
2047 "result",
2048 "",
2049 &resolver,
2050 false,
2051 false,
2052 &per_call_enum_fields,
2053 &HashMap::new(),
2054 false,
2055 );
2056 assert!(
2057 out_merged.contains(".getValue()"),
2058 "merged per-call set must emit .getValue() for status: {out_merged}"
2059 );
2060 }
2061
2062 #[test]
2067 fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2068 use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2069
2070 let batch_object_def = TypeDef {
2072 name: "BatchObject".to_string(),
2073 rust_path: "liter_llm::BatchObject".to_string(),
2074 original_rust_path: String::new(),
2075 fields: vec![
2076 FieldDef {
2077 name: "id".to_string(),
2078 ty: TypeRef::String,
2079 optional: false,
2080 default: None,
2081 doc: String::new(),
2082 sanitized: false,
2083 is_boxed: false,
2084 type_rust_path: None,
2085 cfg: None,
2086 typed_default: None,
2087 core_wrapper: CoreWrapper::None,
2088 vec_inner_core_wrapper: CoreWrapper::None,
2089 newtype_wrapper: None,
2090 serde_rename: None,
2091 serde_flatten: false,
2092 },
2093 FieldDef {
2094 name: "status".to_string(),
2095 ty: TypeRef::Named("BatchStatus".to_string()),
2096 optional: false,
2097 default: None,
2098 doc: String::new(),
2099 sanitized: false,
2100 is_boxed: false,
2101 type_rust_path: None,
2102 cfg: None,
2103 typed_default: None,
2104 core_wrapper: CoreWrapper::None,
2105 vec_inner_core_wrapper: CoreWrapper::None,
2106 newtype_wrapper: None,
2107 serde_rename: None,
2108 serde_flatten: false,
2109 },
2110 ],
2111 methods: vec![],
2112 is_opaque: false,
2113 is_clone: true,
2114 is_copy: false,
2115 doc: String::new(),
2116 cfg: None,
2117 is_trait: false,
2118 has_default: false,
2119 has_stripped_cfg_fields: false,
2120 is_return_type: true,
2121 serde_rename_all: None,
2122 has_serde: true,
2123 super_traits: vec![],
2124 };
2125
2126 let type_defs = [batch_object_def];
2128 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2129
2130 let status_ty = TypeRef::Named("BatchStatus".to_string());
2132 assert!(
2133 is_enum_typed(&status_ty, &struct_names),
2134 "BatchStatus (not a known struct) should be detected as enum-typed"
2135 );
2136 let id_ty = TypeRef::String;
2137 assert!(
2138 !is_enum_typed(&id_ty, &struct_names),
2139 "String field should NOT be detected as enum-typed"
2140 );
2141
2142 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2144 .iter()
2145 .filter_map(|td| {
2146 let enum_field_names: HashSet<String> = td
2147 .fields
2148 .iter()
2149 .filter(|field| is_enum_typed(&field.ty, &struct_names))
2150 .map(|field| field.name.clone())
2151 .collect();
2152 if enum_field_names.is_empty() {
2153 None
2154 } else {
2155 Some((td.name.clone(), enum_field_names))
2156 }
2157 })
2158 .collect();
2159
2160 let batch_enum_fields = type_enum_fields
2161 .get("BatchObject")
2162 .expect("BatchObject should have enum fields");
2163 assert!(
2164 batch_enum_fields.contains("status"),
2165 "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2166 );
2167 assert!(
2168 !batch_enum_fields.contains("id"),
2169 "BatchObject.id (String) must not be in enum fields"
2170 );
2171
2172 let resolver = FieldResolver::new(
2174 &HashMap::new(),
2175 &HashSet::new(),
2176 &HashSet::new(),
2177 &HashSet::new(),
2178 &HashSet::new(),
2179 );
2180 let assertion = Assertion {
2181 assertion_type: "equals".to_string(),
2182 field: Some("status".to_string()),
2183 value: Some(serde_json::Value::String("validating".to_string())),
2184 values: None,
2185 method: None,
2186 check: None,
2187 args: None,
2188 return_type: None,
2189 };
2190 let mut out = String::new();
2191 render_assertion(
2192 &mut out,
2193 &assertion,
2194 "result",
2195 "",
2196 &resolver,
2197 false,
2198 false,
2199 batch_enum_fields,
2200 &HashMap::new(),
2201 false,
2202 );
2203 assert!(
2204 out.contains(".getValue()"),
2205 "auto-detected enum field must route through .getValue(), got: {out}"
2206 );
2207 }
2208}