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