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 local JAR from kreuzberg binding.
199            // Strip the Maven group prefix (e.g. "group:artifact" → "artifact")
200            // because colons in `files()` path strings are treated as classpath
201            // separators by Gradle on Linux/macOS.
202            let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name);
203            format!(r#"    testImplementation(files("../../target/release/{jar_name}.jar"))"#)
204        }
205    };
206
207    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
208    let junit = maven::JUNIT;
209    let jackson = maven::JACKSON_E2E;
210    let jvm_target = toolchain::JVM_TARGET;
211    let launcher_dep = if needs_mock_server {
212        format!(r#"    testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
213    } else {
214        String::new()
215    };
216    format!(
217        r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
218
219plugins {{
220    kotlin("jvm") version "{kotlin_plugin}"
221}}
222
223group = "{kotlin_pkg_id}"
224version = "0.1.0"
225
226java {{
227    sourceCompatibility = JavaVersion.VERSION_{jvm_target}
228    targetCompatibility = JavaVersion.VERSION_{jvm_target}
229}}
230
231kotlin {{
232    compilerOptions {{
233        jvmTarget.set(JvmTarget.JVM_{jvm_target})
234    }}
235}}
236
237repositories {{
238    mavenCentral()
239}}
240
241dependencies {{
242{dep_block}
243    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
244    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
245{launcher_dep}
246    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
247    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
248    testImplementation(kotlin("test"))
249}}
250
251tasks.test {{
252    useJUnitPlatform()
253    val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
254    systemProperty("java.library.path", libPath)
255    systemProperty("jna.library.path", libPath)
256}}
257"#
258    )
259}
260
261/// Render the JUnit Platform `LauncherSessionListener` that spawns the
262/// mock-server binary once per launcher session and tears it down on close.
263///
264/// Mirrors the Java `MockServerListener.java` — same logic, idiomatic Kotlin.
265/// The URL is exposed via `System.setProperty("mockServerUrl", url)`;
266/// generated test bodies read `System.getenv("MOCK_SERVER_URL")` (which the
267/// listener also honours to skip spawning when the caller already has the
268/// server running).
269fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
270    let header = hash::header(CommentStyle::DoubleSlash);
271    format!(
272        r#"{header}package {kotlin_pkg_id}.e2e
273
274import java.io.BufferedReader
275import java.io.IOException
276import java.io.InputStreamReader
277import java.nio.charset.StandardCharsets
278import java.nio.file.Path
279import java.nio.file.Paths
280import java.util.regex.Pattern
281import org.junit.platform.launcher.LauncherSession
282import org.junit.platform.launcher.LauncherSessionListener
283
284/**
285 * Spawns the mock-server binary once per JUnit launcher session and
286 * exposes its URL as the `mockServerUrl` system property. Generated
287 * test bodies read the property (with `MOCK_SERVER_URL` env-var
288 * fallback) so tests can run via plain `./gradlew test` without any
289 * external mock-server orchestration. Mirrors the Ruby spec_helper /
290 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
291 * skipping the spawn entirely.
292 */
293class MockServerListener : LauncherSessionListener {{
294    private var mockServer: Process? = null
295
296    override fun launcherSessionOpened(session: LauncherSession) {{
297        val preset = System.getenv("MOCK_SERVER_URL")
298        if (!preset.isNullOrEmpty()) {{
299            System.setProperty("mockServerUrl", preset)
300            return
301        }}
302        val repoRoot = locateRepoRoot()
303            ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
304        val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
305        val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
306        val fixturesDir = repoRoot.resolve("fixtures").toFile()
307        check(bin.exists()) {{
308            "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
309        }}
310        val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
311            .redirectErrorStream(false)
312        val server = try {{
313            pb.start()
314        }} catch (e: IOException) {{
315            throw IllegalStateException("MockServerListener: failed to start mock-server", e)
316        }}
317        mockServer = server
318        // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
319        // Cap the loop so a misbehaving mock-server cannot block indefinitely.
320        val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
321        var url: String? = null
322        try {{
323            for (i in 0 until 16) {{
324                val line = stdout.readLine() ?: break
325                when {{
326                    line.startsWith("MOCK_SERVER_URL=") -> {{
327                        url = line.removePrefix("MOCK_SERVER_URL=").trim()
328                    }}
329                    line.startsWith("MOCK_SERVERS=") -> {{
330                        val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
331                        System.setProperty("mockServers", jsonVal)
332                        // Parse JSON map of fixture_id -> url and expose as system properties.
333                        val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
334                        val matcher = p.matcher(jsonVal)
335                        while (matcher.find()) {{
336                            System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
337                        }}
338                        break
339                    }}
340                    url != null -> break
341                }}
342            }}
343        }} catch (e: IOException) {{
344            server.destroyForcibly()
345            throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
346        }}
347        if (url.isNullOrEmpty()) {{
348            server.destroyForcibly()
349            error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
350        }}
351        // TCP-readiness probe: ensure axum::serve is accepting before tests start.
352        // The mock-server binds the TcpListener synchronously then prints the URL
353        // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
354        // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
355        val healthUri = java.net.URI.create(url)
356        val host = healthUri.host
357        val port = healthUri.port
358        val deadline = System.nanoTime() + 5_000_000_000L
359        while (System.nanoTime() < deadline) {{
360            try {{
361                java.net.Socket().use {{ s ->
362                    s.connect(java.net.InetSocketAddress(host, port), 100)
363                    break
364                }}
365            }} catch (_: java.io.IOException) {{
366                try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
367            }}
368        }}
369        System.setProperty("mockServerUrl", url)
370        // Drain remaining stdout/stderr in daemon threads so a full pipe
371        // does not block the child.
372        Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
373        Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
374    }}
375
376    override fun launcherSessionClosed(session: LauncherSession) {{
377        val server = mockServer ?: return
378        try {{ server.outputStream.close() }} catch (_: IOException) {{}}
379        try {{
380            if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
381                server.destroyForcibly()
382            }}
383        }} catch (ie: InterruptedException) {{
384            Thread.currentThread().interrupt()
385            server.destroyForcibly()
386        }}
387    }}
388
389    companion object {{
390        private fun locateRepoRoot(): Path? {{
391            var dir: Path? = Paths.get("").toAbsolutePath()
392            while (dir != null) {{
393                if (dir.resolve("fixtures").toFile().isDirectory
394                    && dir.resolve("e2e").toFile().isDirectory) {{
395                    return dir
396                }}
397                dir = dir.parent
398            }}
399            return null
400        }}
401
402        private fun drain(reader: BufferedReader) {{
403            try {{
404                val buf = CharArray(1024)
405                while (reader.read(buf) >= 0) {{ /* drain */ }}
406            }} catch (_: IOException) {{}}
407        }}
408    }}
409}}
410"#
411    )
412}
413
414#[allow(clippy::too_many_arguments)]
415fn render_test_file(
416    category: &str,
417    fixtures: &[&Fixture],
418    class_name: &str,
419    function_name: &str,
420    kotlin_pkg_id: &str,
421    result_var: &str,
422    args: &[crate::config::ArgMapping],
423    options_type: Option<&str>,
424    field_resolver: &FieldResolver,
425    result_is_simple: bool,
426    enum_fields: &HashSet<String>,
427    e2e_config: &E2eConfig,
428) -> String {
429    let mut out = String::new();
430    out.push_str(&hash::header(CommentStyle::DoubleSlash));
431    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
432
433    // If the class_name is fully qualified (contains '.'), import it and use
434    // only the simple name for method calls. Otherwise use it as-is.
435    let (import_path, simple_class) = if class_name.contains('.') {
436        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
437        (class_name, simple)
438    } else {
439        ("", class_name)
440    };
441
442    let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
443    let _ = writeln!(out);
444
445    // Detect if any fixture in this group is an HTTP server test.
446    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
447
448    // Collect every (per-call) options_type referenced by fixtures in this file.
449    // Per-call kotlin overrides win over the file-level options_type passed in.
450    // Each entry is a json_object arg's options_type — we need to import each one.
451    let mut per_fixture_options_types: HashSet<String> = HashSet::new();
452    for f in fixtures.iter() {
453        let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
454        let call_overrides = cc.overrides.get("kotlin");
455        let effective_opts = call_overrides.and_then(|o| o.options_type.as_deref()).or(options_type);
456        if let Some(opts) = effective_opts {
457            // Prefer the per-call args (which carry the correct arg_type + field for the
458            // resolved call); fall back to the file-level args only when the call has none.
459            let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
460            let has_json_obj = fixture_args
461                .iter()
462                .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&f.input, &arg.field).is_null());
463            if has_json_obj {
464                per_fixture_options_types.insert(opts.to_string());
465            }
466        }
467    }
468    let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
469    // Also need ObjectMapper when a handle arg has a non-null config.
470    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
471        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
472            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
473            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
474        })
475    });
476    // HTTP fixtures always need ObjectMapper for JSON body comparison.
477    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
478
479    let _ = writeln!(out, "import org.junit.jupiter.api.Test");
480    let _ = writeln!(out, "import kotlin.test.assertEquals");
481    let _ = writeln!(out, "import kotlin.test.assertTrue");
482    let _ = writeln!(out, "import kotlin.test.assertFalse");
483    let _ = writeln!(out, "import kotlin.test.assertFailsWith");
484    // Only import the binding class when there are non-HTTP fixtures that call it.
485    let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
486    if has_call_fixtures && !import_path.is_empty() {
487        let _ = writeln!(out, "import {import_path}");
488    }
489    if needs_object_mapper {
490        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
491        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
492    }
493    // Import every options type referenced by per-call kotlin overrides in this file.
494    if needs_object_mapper && has_call_fixtures {
495        let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
496        sorted_opts.sort();
497        for opts_type in sorted_opts {
498            let opts_package = if !import_path.is_empty() {
499                let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
500                format!("{pkg}.{opts_type}")
501            } else {
502                opts_type.clone()
503            };
504            let _ = writeln!(out, "import {opts_package}");
505        }
506    }
507    // Import CrawlConfig when handle args need JSON deserialization.
508    if needs_object_mapper_for_handle && !import_path.is_empty() {
509        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
510        let _ = writeln!(out, "import {pkg}.CrawlConfig");
511    }
512    let _ = writeln!(out);
513
514    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
515    let _ = writeln!(out, "class {test_class_name} {{");
516
517    if needs_object_mapper {
518        let _ = writeln!(out);
519        let _ = writeln!(out, "    companion object {{");
520        let _ = writeln!(
521            out,
522            "        private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
523        );
524        let _ = writeln!(out, "    }}");
525    }
526
527    for fixture in fixtures {
528        render_test_method(
529            &mut out,
530            fixture,
531            simple_class,
532            function_name,
533            result_var,
534            args,
535            options_type,
536            field_resolver,
537            result_is_simple,
538            enum_fields,
539            e2e_config,
540        );
541        let _ = writeln!(out);
542    }
543
544    let _ = writeln!(out, "}}");
545    out
546}
547
548// ---------------------------------------------------------------------------
549// HTTP server test rendering — TestClientRenderer impl + thin driver wrapper
550// ---------------------------------------------------------------------------
551
552/// Renderer that emits JUnit 5 `@Test fun testFoo()` blocks using
553/// `java.net.http.HttpClient` against `System.getenv("MOCK_SERVER_URL")`.
554struct KotlinTestClientRenderer;
555
556impl client::TestClientRenderer for KotlinTestClientRenderer {
557    fn language_name(&self) -> &'static str {
558        "kotlin"
559    }
560
561    fn sanitize_test_name(&self, id: &str) -> String {
562        sanitize_ident(id).to_upper_camel_case()
563    }
564
565    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
566        let _ = writeln!(out, "    @Test");
567        let _ = writeln!(out, "    fun test{fn_name}() {{");
568        let _ = writeln!(out, "        // {description}");
569        if let Some(reason) = skip_reason {
570            let escaped = escape_kotlin(reason);
571            let _ = writeln!(
572                out,
573                "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
574            );
575        }
576    }
577
578    fn render_test_close(&self, out: &mut String) {
579        let _ = writeln!(out, "    }}");
580    }
581
582    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
583        let method = ctx.method.to_uppercase();
584        let fixture_path = ctx.path;
585
586        // Java's HttpClient restricts certain headers that cannot be set programmatically.
587        const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
588
589        let _ = writeln!(
590            out,
591            "        val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
592        );
593        let _ = writeln!(out, "        val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
594
595        let body_publisher = if let Some(body) = ctx.body {
596            let json = serde_json::to_string(body).unwrap_or_default();
597            let escaped = escape_kotlin(&json);
598            format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
599        } else {
600            "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
601        };
602
603        let _ = writeln!(out, "        val builder = java.net.http.HttpRequest.newBuilder(uri)");
604        let _ = writeln!(out, "            .method(\"{method}\", {body_publisher})");
605
606        // Content-Type header when there is a body.
607        if ctx.body.is_some() {
608            let content_type = ctx.content_type.unwrap_or("application/json");
609            let _ = writeln!(out, "            .header(\"Content-Type\", \"{content_type}\")");
610        }
611
612        // Explicit request headers (sorted for deterministic output).
613        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
614        header_pairs.sort_by_key(|(k, _)| k.as_str());
615        for (name, value) in &header_pairs {
616            if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
617                continue;
618            }
619            let escaped_name = escape_kotlin(name);
620            let escaped_value = escape_kotlin(value);
621            let _ = writeln!(out, "            .header(\"{escaped_name}\", \"{escaped_value}\")");
622        }
623
624        // Cookies as a single Cookie header.
625        if !ctx.cookies.is_empty() {
626            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
627            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
628            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
629            let cookie_header = escape_kotlin(&cookie_str.join("; "));
630            let _ = writeln!(out, "            .header(\"Cookie\", \"{cookie_header}\")");
631        }
632
633        let _ = writeln!(
634            out,
635            "        val {} = java.net.http.HttpClient.newHttpClient()",
636            ctx.response_var
637        );
638        let _ = writeln!(
639            out,
640            "            .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
641        );
642    }
643
644    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
645        let _ = writeln!(
646            out,
647            "        assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
648        );
649    }
650
651    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
652        let escaped_name = escape_kotlin(name);
653        match expected {
654            "<<present>>" => {
655                let _ = writeln!(
656                    out,
657                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
658                );
659            }
660            "<<absent>>" => {
661                let _ = writeln!(
662                    out,
663                    "        assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
664                );
665            }
666            "<<uuid>>" => {
667                let _ = writeln!(
668                    out,
669                    "        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\")"
670                );
671            }
672            exact => {
673                let escaped_value = escape_kotlin(exact);
674                let _ = writeln!(
675                    out,
676                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
677                );
678            }
679        }
680    }
681
682    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
683        match expected {
684            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
685                let json_str = serde_json::to_string(expected).unwrap_or_default();
686                let escaped = escape_kotlin(&json_str);
687                let _ = writeln!(out, "        val bodyJson = MAPPER.readTree({response_var}.body())");
688                let _ = writeln!(out, "        val expectedJson = MAPPER.readTree(\"{escaped}\")");
689                let _ = writeln!(out, "        assertEquals(expectedJson, bodyJson, \"body mismatch\")");
690            }
691            serde_json::Value::String(s) => {
692                let escaped = escape_kotlin(s);
693                let _ = writeln!(
694                    out,
695                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
696                );
697            }
698            other => {
699                let escaped = escape_kotlin(&other.to_string());
700                let _ = writeln!(
701                    out,
702                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
703                );
704            }
705        }
706    }
707
708    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
709        if let Some(obj) = expected.as_object() {
710            let _ = writeln!(out, "        val _partialTree = MAPPER.readTree({response_var}.body())");
711            for (key, val) in obj {
712                let escaped_key = escape_kotlin(key);
713                match val {
714                    serde_json::Value::String(s) => {
715                        let escaped_val = escape_kotlin(s);
716                        let _ = writeln!(
717                            out,
718                            "        assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
719                        );
720                    }
721                    serde_json::Value::Bool(b) => {
722                        let _ = writeln!(
723                            out,
724                            "        assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
725                        );
726                    }
727                    serde_json::Value::Number(n) => {
728                        let _ = writeln!(
729                            out,
730                            "        assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
731                        );
732                    }
733                    other => {
734                        let json_str = serde_json::to_string(other).unwrap_or_default();
735                        let escaped_val = escape_kotlin(&json_str);
736                        let _ = writeln!(
737                            out,
738                            "        assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
739                        );
740                    }
741                }
742            }
743        }
744    }
745
746    fn render_assert_validation_errors(
747        &self,
748        out: &mut String,
749        response_var: &str,
750        errors: &[ValidationErrorExpectation],
751    ) {
752        let _ = writeln!(out, "        val _veTree = MAPPER.readTree({response_var}.body())");
753        let _ = writeln!(out, "        val _veErrors = _veTree.path(\"errors\")");
754        for ve in errors {
755            let escaped_msg = escape_kotlin(&ve.msg);
756            let _ = writeln!(
757                out,
758                "        assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
759            );
760        }
761    }
762}
763
764/// Render a JUnit 5 `@Test` method for an HTTP server fixture via the shared driver.
765///
766/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because Java's
767/// `HttpClient` cannot handle protocol-switch responses (throws `EOFException`).
768fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
769    // HTTP 101 (WebSocket upgrade) — java.net.http.HttpClient cannot handle upgrade responses.
770    if http.expected_response.status_code == 101 {
771        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
772        let description = &fixture.description;
773        let _ = writeln!(out, "    @Test");
774        let _ = writeln!(out, "    fun test{method_name}() {{");
775        let _ = writeln!(out, "        // {description}");
776        let _ = writeln!(
777            out,
778            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
779        );
780        let _ = writeln!(out, "    }}");
781        return;
782    }
783
784    client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
785}
786
787#[allow(clippy::too_many_arguments)]
788fn render_test_method(
789    out: &mut String,
790    fixture: &Fixture,
791    class_name: &str,
792    _function_name: &str,
793    _result_var: &str,
794    _args: &[crate::config::ArgMapping],
795    options_type: Option<&str>,
796    field_resolver: &FieldResolver,
797    result_is_simple: bool,
798    enum_fields: &HashSet<String>,
799    e2e_config: &E2eConfig,
800) {
801    // Delegate HTTP fixtures to the HTTP-specific renderer.
802    if let Some(http) = &fixture.http {
803        render_http_test_method(out, fixture, http);
804        return;
805    }
806
807    // Resolve per-fixture call config (supports named calls via fixture.call field).
808    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
809    let lang = "kotlin";
810    let call_overrides = call_config.overrides.get(lang);
811
812    // Check for client_factory — when set, use instance-method call style.
813    // Falls back to the global `[e2e.call.overrides.kotlin]` `client_factory` when
814    // a per-call override is absent, matching the dart/swift renderers.
815    let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
816        e2e_config
817            .call
818            .overrides
819            .get(lang)
820            .and_then(|o| o.client_factory.as_deref())
821    });
822
823    let effective_function_name = call_overrides
824        .and_then(|o| o.function.as_ref())
825        .cloned()
826        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
827    let effective_result_var = &call_config.result_var;
828    let effective_args = &call_config.args;
829    let function_name = effective_function_name.as_str();
830    let result_var = effective_result_var.as_str();
831    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
832    // Per-call override of options_type wins over the global one passed in.
833    let effective_options_type = call_overrides.and_then(|o| o.options_type.as_deref()).or(options_type);
834    let options_type = effective_options_type;
835
836    let method_name = fixture.id.to_upper_camel_case();
837    let description = &fixture.description;
838    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
839
840    // Check if this test needs ObjectMapper deserialization for json_object args.
841    // Uses `resolve_field` so that `field = "input"` resolves to the whole fixture
842    // input (and not a nested key called "input"), matching dart/swift behavior.
843    let needs_deser = options_type.is_some()
844        && args
845            .iter()
846            .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&fixture.input, &arg.field).is_null());
847
848    let _ = writeln!(out, "    @Test");
849    let _ = writeln!(out, "    fun test{method_name}() {{");
850    let _ = writeln!(out, "        // {description}");
851
852    // Emit ObjectMapper deserialization bindings for json_object args.
853    if let (true, Some(opts_type)) = (needs_deser, options_type) {
854        for arg in args {
855            if arg.arg_type == "json_object" {
856                let val = super::resolve_field(&fixture.input, &arg.field);
857                if !val.is_null() {
858                    let normalized = super::normalize_json_keys_to_snake_case(val);
859                    let json_str = serde_json::to_string(&normalized).unwrap_or_default();
860                    let var_name = &arg.name;
861                    let _ = writeln!(
862                        out,
863                        "        val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
864                        escape_kotlin(&json_str)
865                    );
866                }
867            }
868        }
869    }
870
871    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
872
873    // When client_factory is set, emit client-object instantiation + instance method call.
874    // The factory name is a function on the Kotlin facade object (e.g. `LiterLlm.createClient`)
875    // that constructs the coroutine-friendly Kotlin client wrapper from the
876    // raw apiKey + baseUrl pair the test owns.
877    if let Some(factory) = client_factory {
878        let fixture_id = &fixture.id;
879        let mock_url_expr = format!("System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"");
880        for line in &setup_lines {
881            let _ = writeln!(out, "        {line}");
882        }
883        let _ = writeln!(
884            out,
885            "        val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
886        );
887        if expects_error {
888            let _ = writeln!(out, "        assertFailsWith<Exception> {{");
889            let _ = writeln!(out, "            client.{function_name}({args_str})");
890            let _ = writeln!(out, "        }}");
891            let _ = writeln!(out, "        client.close()");
892            let _ = writeln!(out, "    }}");
893            return;
894        }
895        let _ = writeln!(out, "        val {result_var} = client.{function_name}({args_str})");
896        for assertion in &fixture.assertions {
897            render_assertion(
898                out,
899                assertion,
900                result_var,
901                class_name,
902                field_resolver,
903                result_is_simple,
904                enum_fields,
905            );
906        }
907        let _ = writeln!(out, "        client.close()");
908        let _ = writeln!(out, "    }}");
909        return;
910    }
911
912    // Flat-function call style (no client_factory).
913    if expects_error {
914        // Wrap setup + call in assertFailsWith so validation errors thrown
915        // during engine creation are also caught (mirrors Java's assertThrows).
916        let _ = writeln!(out, "        assertFailsWith<Exception> {{");
917        for line in &setup_lines {
918            let _ = writeln!(out, "            {line}");
919        }
920        let _ = writeln!(out, "            {class_name}.{function_name}({args_str})");
921        let _ = writeln!(out, "        }}");
922        let _ = writeln!(out, "    }}");
923        return;
924    }
925
926    for line in &setup_lines {
927        let _ = writeln!(out, "        {line}");
928    }
929
930    let _ = writeln!(
931        out,
932        "        val {result_var} = {class_name}.{function_name}({args_str})"
933    );
934
935    for assertion in &fixture.assertions {
936        render_assertion(
937            out,
938            assertion,
939            result_var,
940            class_name,
941            field_resolver,
942            result_is_simple,
943            enum_fields,
944        );
945    }
946
947    let _ = writeln!(out, "    }}");
948}
949
950/// Build setup lines and the argument list for the function call.
951///
952/// Returns `(setup_lines, args_string)`.
953fn build_args_and_setup(
954    input: &serde_json::Value,
955    args: &[crate::config::ArgMapping],
956    class_name: &str,
957    options_type: Option<&str>,
958    fixture_id: &str,
959) -> (Vec<String>, String) {
960    if args.is_empty() {
961        return (Vec::new(), String::new());
962    }
963
964    let mut setup_lines: Vec<String> = Vec::new();
965    let mut parts: Vec<String> = Vec::new();
966
967    for arg in args {
968        if arg.arg_type == "mock_url" {
969            setup_lines.push(format!(
970                "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
971                arg.name,
972            ));
973            parts.push(arg.name.clone());
974            continue;
975        }
976
977        if arg.arg_type == "handle" {
978            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
979            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
980            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
981            if config_value.is_null()
982                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
983            {
984                setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
985            } else {
986                let json_str = serde_json::to_string(config_value).unwrap_or_default();
987                let name = &arg.name;
988                setup_lines.push(format!(
989                    "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
990                    escape_kotlin(&json_str),
991                ));
992                setup_lines.push(format!(
993                    "val {} = {class_name}.{constructor_name}({name}Config)",
994                    arg.name,
995                    name = name,
996                ));
997            }
998            parts.push(arg.name.clone());
999            continue;
1000        }
1001
1002        // Use resolve_field so field = "input" resolves to the whole fixture input.
1003        let val_resolved = super::resolve_field(input, &arg.field);
1004        let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1005            None
1006        } else {
1007            Some(val_resolved)
1008        };
1009        match val {
1010            None | Some(serde_json::Value::Null) if arg.optional => {
1011                continue;
1012            }
1013            None | Some(serde_json::Value::Null) => {
1014                let default_val = match arg.arg_type.as_str() {
1015                    "string" => "\"\"".to_string(),
1016                    "int" | "integer" => "0".to_string(),
1017                    "float" | "number" => "0.0".to_string(),
1018                    "bool" | "boolean" => "false".to_string(),
1019                    _ => "null".to_string(),
1020                };
1021                parts.push(default_val);
1022            }
1023            Some(v) => {
1024                // For json_object args with options_type, use the pre-deserialized variable.
1025                if arg.arg_type == "json_object" && options_type.is_some() {
1026                    parts.push(arg.name.clone());
1027                    continue;
1028                }
1029                // bytes args must be passed as ByteArray.
1030                if arg.arg_type == "bytes" {
1031                    let val = json_to_kotlin(v);
1032                    parts.push(format!("{val}.toByteArray()"));
1033                    continue;
1034                }
1035                parts.push(json_to_kotlin(v));
1036            }
1037        }
1038    }
1039
1040    (setup_lines, parts.join(", "))
1041}
1042
1043fn render_assertion(
1044    out: &mut String,
1045    assertion: &Assertion,
1046    result_var: &str,
1047    _class_name: &str,
1048    field_resolver: &FieldResolver,
1049    result_is_simple: bool,
1050    enum_fields: &HashSet<String>,
1051) {
1052    // Skip assertions on fields that don't exist on the result type.
1053    if let Some(f) = &assertion.field {
1054        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1055            let _ = writeln!(out, "        // skipped: field '{{f}}' not available on result type");
1056            return;
1057        }
1058    }
1059
1060    // Determine if this field is an enum type.
1061    let field_is_enum = assertion
1062        .field
1063        .as_deref()
1064        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1065
1066    // Raw field accessor — may end with nullable type if field is optional.
1067    let field_expr = if result_is_simple {
1068        result_var.to_string()
1069    } else {
1070        match &assertion.field {
1071            Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1072            _ => result_var.to_string(),
1073        }
1074    };
1075
1076    // Whether the accessor may return a nullable type in Kotlin. This is true
1077    // when the leaf field OR any intermediate segment in the path is optional
1078    // (the `?.` safe-call propagates null through the whole chain).
1079    let field_is_optional = !result_is_simple
1080        && assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1081            let resolved = field_resolver.resolve(f);
1082            if field_resolver.has_map_access(f) {
1083                return false;
1084            }
1085            // Check the leaf field itself.
1086            if field_resolver.is_optional(resolved) {
1087                return true;
1088            }
1089            // Also check every prefix segment: if any intermediate field is
1090            // optional the ?.  chain propagates null to the final result.
1091            let mut prefix = String::new();
1092            for part in resolved.split('.') {
1093                // Strip array notation for the lookup key.
1094                let key = part.split('[').next().unwrap_or(part);
1095                if !prefix.is_empty() {
1096                    prefix.push('.');
1097                }
1098                prefix.push_str(key);
1099                if field_resolver.is_optional(&prefix) {
1100                    return true;
1101                }
1102            }
1103            false
1104        });
1105
1106    // String-context expression: append .orEmpty() for nullable string fields so
1107    // string operations (contains, trim) don't require a safe-call chain.
1108    let string_field_expr = if field_is_optional {
1109        format!("{field_expr}.orEmpty()")
1110    } else {
1111        field_expr.clone()
1112    };
1113
1114    // Non-null expression: use !! to assert presence for numeric comparisons where
1115    // the fixture guarantees the value is non-null.
1116    let nonnull_field_expr = if field_is_optional {
1117        format!("{field_expr}!!")
1118    } else {
1119        field_expr.clone()
1120    };
1121
1122    // For enum fields, use .getValue() to get the string value.
1123    let string_expr = if field_is_enum {
1124        format!("{string_field_expr}.getValue()")
1125    } else {
1126        string_field_expr.clone()
1127    };
1128
1129    match assertion.assertion_type.as_str() {
1130        "equals" => {
1131            if let Some(expected) = &assertion.value {
1132                let kotlin_val = json_to_kotlin(expected);
1133                if expected.is_string() {
1134                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {string_expr}.trim())");
1135                } else {
1136                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {nonnull_field_expr})");
1137                }
1138            }
1139        }
1140        "contains" => {
1141            if let Some(expected) = &assertion.value {
1142                let kotlin_val = json_to_kotlin(expected);
1143                let _ = writeln!(
1144                    out,
1145                    "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1146                );
1147            }
1148        }
1149        "contains_all" => {
1150            if let Some(values) = &assertion.values {
1151                for val in values {
1152                    let kotlin_val = json_to_kotlin(val);
1153                    let _ = writeln!(
1154                        out,
1155                        "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1156                    );
1157                }
1158            }
1159        }
1160        "not_contains" => {
1161            if let Some(expected) = &assertion.value {
1162                let kotlin_val = json_to_kotlin(expected);
1163                let _ = writeln!(
1164                    out,
1165                    "        assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1166                );
1167            }
1168        }
1169        "not_empty" => {
1170            let _ = writeln!(
1171                out,
1172                "        assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1173            );
1174        }
1175        "is_empty" => {
1176            let _ = writeln!(
1177                out,
1178                "        assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1179            );
1180        }
1181        "contains_any" => {
1182            if let Some(values) = &assertion.values {
1183                let checks: Vec<String> = values
1184                    .iter()
1185                    .map(|v| {
1186                        let kotlin_val = json_to_kotlin(v);
1187                        format!("{string_expr}.contains({kotlin_val})")
1188                    })
1189                    .collect();
1190                let joined = checks.join(" || ");
1191                let _ = writeln!(
1192                    out,
1193                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1194                );
1195            }
1196        }
1197        "greater_than" => {
1198            if let Some(val) = &assertion.value {
1199                let kotlin_val = json_to_kotlin(val);
1200                let _ = writeln!(
1201                    out,
1202                    "        assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
1203                );
1204            }
1205        }
1206        "less_than" => {
1207            if let Some(val) = &assertion.value {
1208                let kotlin_val = json_to_kotlin(val);
1209                let _ = writeln!(
1210                    out,
1211                    "        assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
1212                );
1213            }
1214        }
1215        "greater_than_or_equal" => {
1216            if let Some(val) = &assertion.value {
1217                let kotlin_val = json_to_kotlin(val);
1218                let _ = writeln!(
1219                    out,
1220                    "        assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
1221                );
1222            }
1223        }
1224        "less_than_or_equal" => {
1225            if let Some(val) = &assertion.value {
1226                let kotlin_val = json_to_kotlin(val);
1227                let _ = writeln!(
1228                    out,
1229                    "        assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
1230                );
1231            }
1232        }
1233        "starts_with" => {
1234            if let Some(expected) = &assertion.value {
1235                let kotlin_val = json_to_kotlin(expected);
1236                let _ = writeln!(
1237                    out,
1238                    "        assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1239                );
1240            }
1241        }
1242        "ends_with" => {
1243            if let Some(expected) = &assertion.value {
1244                let kotlin_val = json_to_kotlin(expected);
1245                let _ = writeln!(
1246                    out,
1247                    "        assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1248                );
1249            }
1250        }
1251        "min_length" => {
1252            if let Some(val) = &assertion.value {
1253                if let Some(n) = val.as_u64() {
1254                    let _ = writeln!(
1255                        out,
1256                        "        assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1257                    );
1258                }
1259            }
1260        }
1261        "max_length" => {
1262            if let Some(val) = &assertion.value {
1263                if let Some(n) = val.as_u64() {
1264                    let _ = writeln!(
1265                        out,
1266                        "        assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1267                    );
1268                }
1269            }
1270        }
1271        "count_min" => {
1272            if let Some(val) = &assertion.value {
1273                if let Some(n) = val.as_u64() {
1274                    let _ = writeln!(
1275                        out,
1276                        "        assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1277                    );
1278                }
1279            }
1280        }
1281        "count_equals" => {
1282            if let Some(val) = &assertion.value {
1283                if let Some(n) = val.as_u64() {
1284                    let _ = writeln!(
1285                        out,
1286                        "        assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1287                    );
1288                }
1289            }
1290        }
1291        "is_true" => {
1292            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\")");
1293        }
1294        "is_false" => {
1295            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\")");
1296        }
1297        "matches_regex" => {
1298            if let Some(expected) = &assertion.value {
1299                let kotlin_val = json_to_kotlin(expected);
1300                let _ = writeln!(
1301                    out,
1302                    "        assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1303                );
1304            }
1305        }
1306        "not_error" => {
1307            // Already handled by the call succeeding without exception.
1308        }
1309        "error" => {
1310            // Handled at the test method level.
1311        }
1312        "method_result" => {
1313            // Placeholder: Kotlin support for method_result would need tree-sitter integration.
1314            let _ = writeln!(
1315                out,
1316                "        // method_result assertions not yet implemented for Kotlin"
1317            );
1318        }
1319        other => {
1320            panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1321        }
1322    }
1323}
1324
1325/// Convert a `serde_json::Value` to a Kotlin literal string.
1326fn json_to_kotlin(value: &serde_json::Value) -> String {
1327    match value {
1328        serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1329        serde_json::Value::Bool(b) => b.to_string(),
1330        serde_json::Value::Number(n) => {
1331            if n.is_f64() {
1332                format!("{}d", n)
1333            } else {
1334                n.to_string()
1335            }
1336        }
1337        serde_json::Value::Null => "null".to_string(),
1338        serde_json::Value::Array(arr) => {
1339            let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1340            format!("listOf({})", items.join(", "))
1341        }
1342        serde_json::Value::Object(_) => {
1343            let json_str = serde_json::to_string(value).unwrap_or_default();
1344            format!("\"{}\"", escape_kotlin(&json_str))
1345        }
1346    }
1347}