Skip to main content

alef_e2e/codegen/
kotlin.rs

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