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        // Build a map from TypeDef name → set of field names whose Rust type
141        // is a `Named(T)` reference where `T` is NOT itself a known struct.
142        // Those fields are enum-typed and should route through `.getValue()` in
143        // generated assertions automatically, even without an explicit per-call
144        // `enum_fields` override in the alef.toml.
145        let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
146        let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
147            .iter()
148            .filter_map(|td| {
149                let enum_field_names: HashSet<String> = td
150                    .fields
151                    .iter()
152                    .filter(|field| is_enum_typed(&field.ty, &struct_names))
153                    .map(|field| field.name.clone())
154                    .collect();
155                if enum_field_names.is_empty() {
156                    None
157                } else {
158                    Some((td.name.clone(), enum_field_names))
159                }
160            })
161            .collect();
162
163        for group in groups {
164            let active: Vec<&Fixture> = group
165                .fixtures
166                .iter()
167                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
168                .collect();
169
170            if active.is_empty() {
171                continue;
172            }
173
174            let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
175            let content = render_test_file(
176                &group.category,
177                &active,
178                &class_name,
179                &function_name,
180                &kotlin_pkg_id,
181                result_var,
182                &e2e_config.call.args,
183                options_type.as_deref(),
184                &field_resolver,
185                result_is_simple,
186                &e2e_config.fields_enum,
187                e2e_config,
188                &type_enum_fields,
189            );
190            files.push(GeneratedFile {
191                path: test_base.join(class_file_name),
192                content,
193                generated_header: true,
194            });
195        }
196
197        Ok(files)
198    }
199
200    fn language_name(&self) -> &'static str {
201        "kotlin"
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Helpers
207// ---------------------------------------------------------------------------
208
209/// Returns true when `ty` is a `Named(T)` reference (or `Optional<Named(T)>`)
210/// where `T` is **not** a known struct name. Such fields are enum-typed and
211/// must route through `.getValue()` in generated assertions.
212fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
213    use alef_core::ir::TypeRef;
214    match ty {
215        TypeRef::Named(name) => !struct_names.contains(name.as_str()),
216        TypeRef::Optional(inner) => {
217            matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
218        }
219        _ => false,
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Rendering
225// ---------------------------------------------------------------------------
226
227fn render_build_gradle(
228    pkg_name: &str,
229    kotlin_pkg_id: &str,
230    pkg_version: &str,
231    dep_mode: crate::config::DependencyMode,
232    needs_mock_server: bool,
233) -> String {
234    let dep_block = match dep_mode {
235        crate::config::DependencyMode::Registry => {
236            // Registry mode: maven central with group:artifact:version
237            format!(r#"    testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
238        }
239        crate::config::DependencyMode::Local => {
240            // Local mode: reference the kotlin binding's built jar. The Kotlin
241            // module is produced by `gradle build` under
242            // `packages/kotlin/build/libs/<jar_name>-<version>.jar`, not by cargo.
243            // We must also pull in the binding's runtime dependencies (JNA,
244            // Jackson, jspecify, kotlinx-coroutines) since `files()` does not
245            // resolve transitive metadata.
246            let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name).replace('-', "_");
247            let jna = maven::JNA;
248            let jackson = maven::JACKSON_E2E;
249            let jspecify = maven::JSPECIFY;
250            let coroutines = maven::KOTLINX_COROUTINES_CORE;
251            format!(
252                r#"    testImplementation(files("../../packages/kotlin/build/libs/{jar_name}-{pkg_version}.jar"))
253    testImplementation("net.java.dev.jna:jna:{jna}")
254    testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
255    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
256    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
257    testImplementation("org.jspecify:jspecify:{jspecify}")
258    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")"#
259            )
260        }
261    };
262
263    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
264    let junit = maven::JUNIT;
265    let jackson = maven::JACKSON_E2E;
266    let jvm_target = toolchain::JVM_TARGET;
267    let launcher_dep = if needs_mock_server {
268        format!(r#"    testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
269    } else {
270        String::new()
271    };
272    format!(
273        r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
274
275plugins {{
276    kotlin("jvm") version "{kotlin_plugin}"
277}}
278
279group = "{kotlin_pkg_id}"
280version = "0.1.0"
281
282java {{
283    sourceCompatibility = JavaVersion.VERSION_{jvm_target}
284    targetCompatibility = JavaVersion.VERSION_{jvm_target}
285}}
286
287kotlin {{
288    compilerOptions {{
289        jvmTarget.set(JvmTarget.JVM_{jvm_target})
290    }}
291}}
292
293repositories {{
294    mavenCentral()
295}}
296
297dependencies {{
298{dep_block}
299    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
300    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
301{launcher_dep}
302    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
303    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
304    testImplementation(kotlin("test"))
305}}
306
307tasks.test {{
308    useJUnitPlatform()
309    val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
310    systemProperty("java.library.path", libPath)
311    systemProperty("jna.library.path", libPath)
312    // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/.
313    workingDir = file("${{rootDir}}/../../test_documents")
314}}
315"#
316    )
317}
318
319/// Render the JUnit Platform `LauncherSessionListener` that spawns the
320/// mock-server binary once per launcher session and tears it down on close.
321///
322/// Mirrors the Java `MockServerListener.java` — same logic, idiomatic Kotlin.
323/// The URL is exposed via `System.setProperty("mockServerUrl", url)`;
324/// generated test bodies read `System.getenv("MOCK_SERVER_URL")` (which the
325/// listener also honours to skip spawning when the caller already has the
326/// server running).
327fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
328    let header = hash::header(CommentStyle::DoubleSlash);
329    format!(
330        r#"{header}package {kotlin_pkg_id}.e2e
331
332import java.io.BufferedReader
333import java.io.IOException
334import java.io.InputStreamReader
335import java.nio.charset.StandardCharsets
336import java.nio.file.Path
337import java.nio.file.Paths
338import java.util.regex.Pattern
339import org.junit.platform.launcher.LauncherSession
340import org.junit.platform.launcher.LauncherSessionListener
341
342/**
343 * Spawns the mock-server binary once per JUnit launcher session and
344 * exposes its URL as the `mockServerUrl` system property. Generated
345 * test bodies read the property (with `MOCK_SERVER_URL` env-var
346 * fallback) so tests can run via plain `./gradlew test` without any
347 * external mock-server orchestration. Mirrors the Ruby spec_helper /
348 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
349 * skipping the spawn entirely.
350 */
351class MockServerListener : LauncherSessionListener {{
352    private var mockServer: Process? = null
353
354    override fun launcherSessionOpened(session: LauncherSession) {{
355        val preset = System.getenv("MOCK_SERVER_URL")
356        if (!preset.isNullOrEmpty()) {{
357            System.setProperty("mockServerUrl", preset)
358            return
359        }}
360        val repoRoot = locateRepoRoot()
361            ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
362        val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
363        val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
364        val fixturesDir = repoRoot.resolve("fixtures").toFile()
365        check(bin.exists()) {{
366            "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
367        }}
368        val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
369            .redirectErrorStream(false)
370        val server = try {{
371            pb.start()
372        }} catch (e: IOException) {{
373            throw IllegalStateException("MockServerListener: failed to start mock-server", e)
374        }}
375        mockServer = server
376        // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
377        // Cap the loop so a misbehaving mock-server cannot block indefinitely.
378        val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
379        var url: String? = null
380        try {{
381            for (i in 0 until 16) {{
382                val line = stdout.readLine() ?: break
383                when {{
384                    line.startsWith("MOCK_SERVER_URL=") -> {{
385                        url = line.removePrefix("MOCK_SERVER_URL=").trim()
386                    }}
387                    line.startsWith("MOCK_SERVERS=") -> {{
388                        val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
389                        System.setProperty("mockServers", jsonVal)
390                        // Parse JSON map of fixture_id -> url and expose as system properties.
391                        val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
392                        val matcher = p.matcher(jsonVal)
393                        while (matcher.find()) {{
394                            System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
395                        }}
396                        break
397                    }}
398                    url != null -> break
399                }}
400            }}
401        }} catch (e: IOException) {{
402            server.destroyForcibly()
403            throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
404        }}
405        if (url.isNullOrEmpty()) {{
406            server.destroyForcibly()
407            error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
408        }}
409        // TCP-readiness probe: ensure axum::serve is accepting before tests start.
410        // The mock-server binds the TcpListener synchronously then prints the URL
411        // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
412        // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
413        val healthUri = java.net.URI.create(url)
414        val host = healthUri.host
415        val port = healthUri.port
416        val deadline = System.nanoTime() + 5_000_000_000L
417        while (System.nanoTime() < deadline) {{
418            try {{
419                java.net.Socket().use {{ s ->
420                    s.connect(java.net.InetSocketAddress(host, port), 100)
421                    break
422                }}
423            }} catch (_: java.io.IOException) {{
424                try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
425            }}
426        }}
427        System.setProperty("mockServerUrl", url)
428        // Drain remaining stdout/stderr in daemon threads so a full pipe
429        // does not block the child.
430        Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
431        Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
432    }}
433
434    override fun launcherSessionClosed(session: LauncherSession) {{
435        val server = mockServer ?: return
436        try {{ server.outputStream.close() }} catch (_: IOException) {{}}
437        try {{
438            if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
439                server.destroyForcibly()
440            }}
441        }} catch (ie: InterruptedException) {{
442            Thread.currentThread().interrupt()
443            server.destroyForcibly()
444        }}
445    }}
446
447    companion object {{
448        private fun locateRepoRoot(): Path? {{
449            var dir: Path? = Paths.get("").toAbsolutePath()
450            while (dir != null) {{
451                if (dir.resolve("fixtures").toFile().isDirectory
452                    && dir.resolve("e2e").toFile().isDirectory) {{
453                    return dir
454                }}
455                dir = dir.parent
456            }}
457            return null
458        }}
459
460        private fun drain(reader: BufferedReader) {{
461            try {{
462                val buf = CharArray(1024)
463                while (reader.read(buf) >= 0) {{ /* drain */ }}
464            }} catch (_: IOException) {{}}
465        }}
466    }}
467}}
468"#
469    )
470}
471
472#[allow(clippy::too_many_arguments)]
473fn render_test_file(
474    category: &str,
475    fixtures: &[&Fixture],
476    class_name: &str,
477    function_name: &str,
478    kotlin_pkg_id: &str,
479    result_var: &str,
480    args: &[crate::config::ArgMapping],
481    options_type: Option<&str>,
482    field_resolver: &FieldResolver,
483    result_is_simple: bool,
484    enum_fields: &HashSet<String>,
485    e2e_config: &E2eConfig,
486    type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
487) -> String {
488    let mut out = String::new();
489    out.push_str(&hash::header(CommentStyle::DoubleSlash));
490    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
491
492    // If the class_name is fully qualified (contains '.'), import it and use
493    // only the simple name for method calls. Otherwise use it as-is.
494    let (import_path, simple_class) = if class_name.contains('.') {
495        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
496        (class_name, simple)
497    } else {
498        ("", class_name)
499    };
500
501    let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
502    let _ = writeln!(out);
503
504    // Detect if any fixture in this group is an HTTP server test.
505    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
506
507    // Detect if any non-HTTP fixture uses a client_factory (coroutine-based client).
508    // When true, test functions must use `= runBlocking { ... }` to call suspend fns.
509    let has_client_factory_fixtures = fixtures.iter().any(|f| {
510        if f.is_http_test() {
511            return false;
512        }
513        let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
514        let per_call_factory = cc.overrides.get("kotlin").and_then(|o| o.client_factory.as_deref());
515        let global_factory = e2e_config
516            .call
517            .overrides
518            .get("kotlin")
519            .and_then(|o| o.client_factory.as_deref());
520        per_call_factory.or(global_factory).is_some()
521    });
522
523    // Collect every (per-call) options_type referenced by fixtures in this file.
524    // Per-call kotlin overrides win over the file-level options_type passed in.
525    // Each entry is a json_object arg's options_type — we need to import each one.
526    let mut per_fixture_options_types: HashSet<String> = HashSet::new();
527    for f in fixtures.iter() {
528        let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
529        let call_overrides = cc.overrides.get("kotlin");
530        let effective_opts: Option<String> = call_overrides
531            .and_then(|o| o.options_type.clone())
532            .or_else(|| options_type.map(|s| s.to_string()))
533            .or_else(|| {
534                for cand in ["csharp", "c", "go", "php", "python"] {
535                    if let Some(o) = cc.overrides.get(cand) {
536                        if let Some(t) = &o.options_type {
537                            return Some(t.clone());
538                        }
539                    }
540                }
541                None
542            });
543        if let Some(opts) = effective_opts {
544            // Prefer the per-call args (which carry the correct arg_type + field for the
545            // resolved call); fall back to the file-level args only when the call has none.
546            let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
547            // Import the options type if the fixture either supplies a json_object value
548            // (deserialised via ObjectMapper) OR has an *optional* json_object arg with
549            // no value — the generator emits `OptionsType.builder().build()` in that
550            // case to keep the call arity correct.
551            let needs_opts_type = fixture_args.iter().any(|arg| {
552                if arg.arg_type != "json_object" {
553                    return false;
554                }
555                let v = super::resolve_field(&f.input, &arg.field);
556                !v.is_null() || arg.optional
557            });
558            if needs_opts_type {
559                per_fixture_options_types.insert(opts.to_string());
560            }
561        }
562    }
563    let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
564    // Also need ObjectMapper when a handle arg has a non-null config.
565    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
566        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
567            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
568            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
569        })
570    });
571    // HTTP fixtures always need ObjectMapper for JSON body comparison.
572    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
573
574    let _ = writeln!(out, "import org.junit.jupiter.api.Test");
575    let _ = writeln!(out, "import kotlin.test.assertEquals");
576    let _ = writeln!(out, "import kotlin.test.assertTrue");
577    let _ = writeln!(out, "import kotlin.test.assertFalse");
578    let _ = writeln!(out, "import kotlin.test.assertFailsWith");
579    if has_client_factory_fixtures {
580        let _ = writeln!(out, "import kotlinx.coroutines.runBlocking");
581    }
582    // Effective binding package for FQN imports. When the binding `class_name` is
583    // not fully-qualified, fall back to `kotlin_pkg_id` — the kotlin binding emits
584    // top-level typealiases at that package (e.g. `package com.github.kreuzberg_dev`)
585    // while the test files live at `<kotlin_pkg_id>.e2e`. Child packages do NOT
586    // import their parent's symbols implicitly, so explicit imports are required.
587    let binding_pkg_for_imports: String = if !import_path.is_empty() {
588        import_path
589            .rsplit_once('.')
590            .map(|(p, _)| p.to_string())
591            .unwrap_or_else(|| kotlin_pkg_id.to_string())
592    } else {
593        kotlin_pkg_id.to_string()
594    };
595    // Only import the binding class when there are non-HTTP fixtures that call it.
596    let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
597    if has_call_fixtures {
598        if !import_path.is_empty() {
599            let _ = writeln!(out, "import {import_path}");
600        } else if !class_name.is_empty() {
601            let _ = writeln!(out, "import {binding_pkg_for_imports}.{class_name}");
602        }
603    }
604    if needs_object_mapper {
605        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
606        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
607    }
608    // Import every options type referenced by per-call kotlin overrides in this file.
609    // Options-type imports are needed for both ObjectMapper deserialisation and for
610    // optional-arg defaults emitted as `OptionsType.builder().build()`.
611    if has_call_fixtures {
612        let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
613        sorted_opts.sort();
614        for opts_type in sorted_opts {
615            let _ = writeln!(out, "import {binding_pkg_for_imports}.{opts_type}");
616        }
617    }
618    // Import CrawlConfig when handle args need JSON deserialization.
619    if needs_object_mapper_for_handle {
620        let _ = writeln!(out, "import {binding_pkg_for_imports}.CrawlConfig");
621    }
622    // Import BatchBytesItem / BatchFileItem when any fixture has a batch-item
623    // array arg (element_type) — the test code constructs these directly.
624    let mut batch_elem_imports: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
625    for f in fixtures.iter() {
626        let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
627        let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
628        for arg in fixture_args.iter() {
629            if arg.arg_type != "json_object" {
630                continue;
631            }
632            let v = super::resolve_field(&f.input, &arg.field);
633            if !v.is_array() {
634                continue;
635            }
636            if let Some(elem) = &arg.element_type {
637                if elem == "BatchBytesItem" || elem == "BatchFileItem" {
638                    batch_elem_imports.insert(elem.clone());
639                }
640            }
641        }
642    }
643    for elem in &batch_elem_imports {
644        let _ = writeln!(out, "import {binding_pkg_for_imports}.{elem}");
645    }
646    let _ = writeln!(out);
647
648    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
649    let _ = writeln!(out, "class {test_class_name} {{");
650
651    if needs_object_mapper {
652        let _ = writeln!(out);
653        let _ = writeln!(out, "    companion object {{");
654        let _ = writeln!(
655            out,
656            "        private val MAPPER = ObjectMapper().registerModule(Jdk8Module()).setPropertyNamingStrategy(com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE)"
657        );
658        let _ = writeln!(out, "    }}");
659    }
660
661    for fixture in fixtures {
662        render_test_method(
663            &mut out,
664            fixture,
665            simple_class,
666            function_name,
667            result_var,
668            args,
669            options_type,
670            field_resolver,
671            result_is_simple,
672            enum_fields,
673            e2e_config,
674            type_enum_fields,
675        );
676        let _ = writeln!(out);
677    }
678
679    let _ = writeln!(out, "}}");
680    out
681}
682
683// ---------------------------------------------------------------------------
684// HTTP server test rendering — TestClientRenderer impl + thin driver wrapper
685// ---------------------------------------------------------------------------
686
687/// Renderer that emits JUnit 5 `@Test fun testFoo()` blocks using
688/// `java.net.http.HttpClient` against `System.getenv("MOCK_SERVER_URL")`.
689struct KotlinTestClientRenderer;
690
691impl client::TestClientRenderer for KotlinTestClientRenderer {
692    fn language_name(&self) -> &'static str {
693        "kotlin"
694    }
695
696    fn sanitize_test_name(&self, id: &str) -> String {
697        sanitize_ident(id).to_upper_camel_case()
698    }
699
700    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
701        let _ = writeln!(out, "    @Test");
702        let _ = writeln!(out, "    fun test{fn_name}() {{");
703        let _ = writeln!(out, "        // {description}");
704        if let Some(reason) = skip_reason {
705            let escaped = escape_kotlin(reason);
706            let _ = writeln!(
707                out,
708                "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
709            );
710        }
711    }
712
713    fn render_test_close(&self, out: &mut String) {
714        let _ = writeln!(out, "    }}");
715    }
716
717    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
718        let method = ctx.method.to_uppercase();
719        let fixture_path = ctx.path;
720
721        // Java's HttpClient restricts certain headers that cannot be set programmatically.
722        const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
723
724        let _ = writeln!(
725            out,
726            "        val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
727        );
728        let _ = writeln!(out, "        val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
729
730        let body_publisher = if let Some(body) = ctx.body {
731            let json = serde_json::to_string(body).unwrap_or_default();
732            let escaped = escape_kotlin(&json);
733            format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
734        } else {
735            "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
736        };
737
738        let _ = writeln!(out, "        val builder = java.net.http.HttpRequest.newBuilder(uri)");
739        let _ = writeln!(out, "            .method(\"{method}\", {body_publisher})");
740
741        // Content-Type header when there is a body.
742        if ctx.body.is_some() {
743            let content_type = ctx.content_type.unwrap_or("application/json");
744            let _ = writeln!(out, "            .header(\"Content-Type\", \"{content_type}\")");
745        }
746
747        // Explicit request headers (sorted for deterministic output).
748        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
749        header_pairs.sort_by_key(|(k, _)| k.as_str());
750        for (name, value) in &header_pairs {
751            if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
752                continue;
753            }
754            let escaped_name = escape_kotlin(name);
755            let escaped_value = escape_kotlin(value);
756            let _ = writeln!(out, "            .header(\"{escaped_name}\", \"{escaped_value}\")");
757        }
758
759        // Cookies as a single Cookie header.
760        if !ctx.cookies.is_empty() {
761            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
762            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
763            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
764            let cookie_header = escape_kotlin(&cookie_str.join("; "));
765            let _ = writeln!(out, "            .header(\"Cookie\", \"{cookie_header}\")");
766        }
767
768        let _ = writeln!(
769            out,
770            "        val {} = java.net.http.HttpClient.newHttpClient()",
771            ctx.response_var
772        );
773        let _ = writeln!(
774            out,
775            "            .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
776        );
777    }
778
779    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
780        let _ = writeln!(
781            out,
782            "        assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
783        );
784    }
785
786    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
787        let escaped_name = escape_kotlin(name);
788        match expected {
789            "<<present>>" => {
790                let _ = writeln!(
791                    out,
792                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
793                );
794            }
795            "<<absent>>" => {
796                let _ = writeln!(
797                    out,
798                    "        assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
799                );
800            }
801            "<<uuid>>" => {
802                let _ = writeln!(
803                    out,
804                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").matches(\"[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}\"), \"header {escaped_name} should be a UUID\")"
805                );
806            }
807            exact => {
808                let escaped_value = escape_kotlin(exact);
809                let _ = writeln!(
810                    out,
811                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
812                );
813            }
814        }
815    }
816
817    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
818        match expected {
819            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
820                let json_str = serde_json::to_string(expected).unwrap_or_default();
821                let escaped = escape_kotlin(&json_str);
822                let _ = writeln!(out, "        val bodyJson = MAPPER.readTree({response_var}.body())");
823                let _ = writeln!(out, "        val expectedJson = MAPPER.readTree(\"{escaped}\")");
824                let _ = writeln!(out, "        assertEquals(expectedJson, bodyJson, \"body mismatch\")");
825            }
826            serde_json::Value::String(s) => {
827                let escaped = escape_kotlin(s);
828                let _ = writeln!(
829                    out,
830                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
831                );
832            }
833            other => {
834                let escaped = escape_kotlin(&other.to_string());
835                let _ = writeln!(
836                    out,
837                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
838                );
839            }
840        }
841    }
842
843    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
844        if let Some(obj) = expected.as_object() {
845            let _ = writeln!(out, "        val _partialTree = MAPPER.readTree({response_var}.body())");
846            for (key, val) in obj {
847                let escaped_key = escape_kotlin(key);
848                match val {
849                    serde_json::Value::String(s) => {
850                        let escaped_val = escape_kotlin(s);
851                        let _ = writeln!(
852                            out,
853                            "        assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
854                        );
855                    }
856                    serde_json::Value::Bool(b) => {
857                        let _ = writeln!(
858                            out,
859                            "        assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
860                        );
861                    }
862                    serde_json::Value::Number(n) => {
863                        let _ = writeln!(
864                            out,
865                            "        assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
866                        );
867                    }
868                    other => {
869                        let json_str = serde_json::to_string(other).unwrap_or_default();
870                        let escaped_val = escape_kotlin(&json_str);
871                        let _ = writeln!(
872                            out,
873                            "        assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
874                        );
875                    }
876                }
877            }
878        }
879    }
880
881    fn render_assert_validation_errors(
882        &self,
883        out: &mut String,
884        response_var: &str,
885        errors: &[ValidationErrorExpectation],
886    ) {
887        let _ = writeln!(out, "        val _veTree = MAPPER.readTree({response_var}.body())");
888        let _ = writeln!(out, "        val _veErrors = _veTree.path(\"errors\")");
889        for ve in errors {
890            let escaped_msg = escape_kotlin(&ve.msg);
891            let _ = writeln!(
892                out,
893                "        assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
894            );
895        }
896    }
897}
898
899/// Render a JUnit 5 `@Test` method for an HTTP server fixture via the shared driver.
900///
901/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because Java's
902/// `HttpClient` cannot handle protocol-switch responses (throws `EOFException`).
903fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
904    // HTTP 101 (WebSocket upgrade) — java.net.http.HttpClient cannot handle upgrade responses.
905    if http.expected_response.status_code == 101 {
906        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
907        let description = &fixture.description;
908        let _ = writeln!(out, "    @Test");
909        let _ = writeln!(out, "    fun test{method_name}() {{");
910        let _ = writeln!(out, "        // {description}");
911        let _ = writeln!(
912            out,
913            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
914        );
915        let _ = writeln!(out, "    }}");
916        return;
917    }
918
919    client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
920}
921
922#[allow(clippy::too_many_arguments)]
923fn render_test_method(
924    out: &mut String,
925    fixture: &Fixture,
926    class_name: &str,
927    _function_name: &str,
928    _result_var: &str,
929    _args: &[crate::config::ArgMapping],
930    options_type: Option<&str>,
931    field_resolver: &FieldResolver,
932    result_is_simple: bool,
933    enum_fields: &HashSet<String>,
934    e2e_config: &E2eConfig,
935    type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
936) {
937    // Delegate HTTP fixtures to the HTTP-specific renderer.
938    if let Some(http) = &fixture.http {
939        render_http_test_method(out, fixture, http);
940        return;
941    }
942
943    // Resolve per-fixture call config (supports named calls via fixture.call field).
944    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
945    let lang = "kotlin";
946    let call_overrides = call_config.overrides.get(lang);
947
948    // Check for client_factory — when set, use instance-method call style.
949    // Falls back to the global `[e2e.call.overrides.kotlin]` `client_factory` when
950    // a per-call override is absent, matching the dart/swift renderers.
951    let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
952        e2e_config
953            .call
954            .overrides
955            .get(lang)
956            .and_then(|o| o.client_factory.as_deref())
957    });
958
959    let effective_function_name = call_overrides
960        .and_then(|o| o.function.as_ref())
961        .cloned()
962        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
963    let effective_result_var = &call_config.result_var;
964    let effective_args = &call_config.args;
965    let function_name = effective_function_name.as_str();
966    let result_var = effective_result_var.as_str();
967    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
968    // Resolve per-fixture options_type: prefer the kotlin call override, fall back
969    // to class-level, then to any other language's options_type for the same call.
970    // The Kotlin module re-exports Java facade types unchanged, so a type name declared
971    // by csharp/c/go/php/python applies equally to Kotlin without an explicit override.
972    let effective_options_type: Option<String> = call_overrides
973        .and_then(|o| o.options_type.clone())
974        .or_else(|| options_type.map(|s| s.to_string()))
975        .or_else(|| {
976            for cand in ["csharp", "c", "go", "php", "python"] {
977                if let Some(o) = call_config.overrides.get(cand) {
978                    if let Some(t) = &o.options_type {
979                        return Some(t.clone());
980                    }
981                }
982            }
983            None
984        });
985    let options_type = effective_options_type.as_deref();
986
987    // Resolve per-fixture result_is_simple: prefer the kotlin override, then the
988    // class-level default, then any sibling language override (java/csharp/go).
989    // The Kotlin facade shares its return-type shape with the Java facade, so a
990    // declaration in any of those bindings applies to Kotlin too.
991    let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple)
992        || call_config.result_is_simple
993        || result_is_simple
994        || ["java", "csharp", "go"]
995            .iter()
996            .any(|cand| call_config.overrides.get(*cand).is_some_and(|o| o.result_is_simple));
997    let result_is_simple = effective_result_is_simple;
998
999    // Resolve per-fixture result_is_option: prefer the kotlin override, then the
1000    // call-level default. When set the function returns `T?` and bare-result
1001    // emptiness assertions must use a null-check instead of `.isEmpty()`.
1002    let result_is_option = call_overrides.is_some_and(|o| o.result_is_option) || call_config.result_is_option;
1003
1004    let method_name = fixture.id.to_upper_camel_case();
1005    let description = &fixture.description;
1006    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1007
1008    // Streaming detection (call-level `streaming` opt-out is honored).
1009    let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1010    let collect_snippet = if is_streaming && !expects_error {
1011        crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("kotlin", result_var, "chunks")
1012            .unwrap_or_default()
1013    } else {
1014        String::new()
1015    };
1016
1017    // Check if this test needs ObjectMapper deserialization for json_object args.
1018    // Uses `resolve_field` so that `field = "input"` resolves to the whole fixture
1019    // input (and not a nested key called "input"), matching dart/swift behavior.
1020    let needs_deser = options_type.is_some()
1021        && args
1022            .iter()
1023            .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&fixture.input, &arg.field).is_null());
1024
1025    // Merge per-call kotlin enum_fields (HashMap key = field path, value = enum type name)
1026    // into the global fields_enum set so that call-specific enum-typed result fields
1027    // (e.g. `status` on BatchObject) route through `.getValue()` in assertions even
1028    // when absent from the global `fields_enum` list.  Mirrors the Java codegen at
1029    // codegen/java.rs where per-call overrides are merged before assertion rendering.
1030    //
1031    // Additionally, auto-detect enum-typed fields by looking up the call's result type
1032    // in `type_enum_fields` (built from the IR TypeDef list). This handles the common
1033    // case where a field's Rust type is a `Named(EnumName)` that was never explicitly
1034    // listed in the alef.toml `enum_fields` table.
1035    let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
1036        // Resolve the result type name for this call. Prefer the kotlin override, then
1037        // java, then c — the Kotlin facade re-exports Java facade types unchanged.
1038        let result_type_name: Option<&str> = call_overrides
1039            .and_then(|co| co.result_type.as_deref())
1040            .or_else(|| call_config.overrides.get("java").and_then(|o| o.result_type.as_deref()))
1041            .or_else(|| call_config.overrides.get("c").and_then(|o| o.result_type.as_deref()));
1042        let auto_enum_fields: Option<&HashSet<String>> = result_type_name.and_then(|name| type_enum_fields.get(name));
1043        let has_per_call = call_overrides.is_some_and(|co| !co.enum_fields.is_empty());
1044        let has_auto = auto_enum_fields.is_some_and(|f| !f.is_empty());
1045        if has_per_call || has_auto {
1046            let mut merged = enum_fields.clone();
1047            if let Some(co) = call_overrides {
1048                merged.extend(co.enum_fields.keys().cloned());
1049            }
1050            if let Some(auto_fields) = auto_enum_fields {
1051                merged.extend(auto_fields.iter().cloned());
1052            }
1053            std::borrow::Cow::Owned(merged)
1054        } else {
1055            std::borrow::Cow::Borrowed(enum_fields)
1056        }
1057    };
1058    let enum_fields: &HashSet<String> = &effective_enum_fields;
1059
1060    let _ = writeln!(out, "    @Test");
1061    if client_factory.is_some() {
1062        let _ = writeln!(out, "    fun test{method_name}() = runBlocking {{");
1063    } else {
1064        let _ = writeln!(out, "    fun test{method_name}() {{");
1065    }
1066    let _ = writeln!(out, "        // {description}");
1067
1068    // Emit ObjectMapper deserialization bindings for json_object args.
1069    // Object args use the configured `options_type`. Array args carrying
1070    // `element_type = BatchBytesItem | BatchFileItem` are emitted as inline
1071    // List<T> constructors below (build_args_and_setup) — no deser binding is
1072    // needed because the array is materialised directly in source.
1073    if needs_deser {
1074        for arg in args {
1075            if arg.arg_type != "json_object" {
1076                continue;
1077            }
1078            let val = super::resolve_field(&fixture.input, &arg.field);
1079            if val.is_null() {
1080                continue;
1081            }
1082            // Skip arrays that we materialise inline (batch items + primitive
1083            // lists like List<String>) rather than deserialising via Jackson.
1084            if val.is_array() && arg.element_type.is_some() {
1085                continue;
1086            }
1087            let Some(opts_type) = options_type else { continue };
1088            let normalized = super::transform_json_keys_for_language(val, "snake_case");
1089            let json_str = serde_json::to_string(&normalized).unwrap_or_default();
1090            let var_name = &arg.name;
1091            let _ = writeln!(
1092                out,
1093                "        val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
1094                escape_kotlin(&json_str)
1095            );
1096        }
1097    }
1098
1099    let (setup_lines, args_str) =
1100        build_args_and_setup(fixture, &fixture.input, args, class_name, options_type, &fixture.id);
1101
1102    // When client_factory is set, emit client-object instantiation + instance method call.
1103    // The factory name is a function on the Kotlin facade object (e.g. `LiterLlm.createClient`)
1104    // that constructs the coroutine-friendly Kotlin client wrapper from the
1105    // raw apiKey + baseUrl pair the test owns.
1106    if let Some(factory) = client_factory {
1107        let fixture_id = &fixture.id;
1108        // Prefer system properties set by MockServerListener (which spawns the
1109        // mock-server in-process when MOCK_SERVER_URL isn't pre-set). The
1110        // per-fixture property holds the full URL; fall back to the base URL
1111        // (mockServerUrl or env var) with the /fixtures/<id> suffix appended.
1112        let mock_url_expr = format!(
1113            "System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\") ?: \"\") + \"/fixtures/{fixture_id}\")"
1114        );
1115        for line in &setup_lines {
1116            let _ = writeln!(out, "        {line}");
1117        }
1118        let _ = writeln!(
1119            out,
1120            "        val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1121        );
1122        if expects_error {
1123            let _ = writeln!(out, "        assertFailsWith<Exception> {{");
1124            let _ = writeln!(out, "            client.{function_name}({args_str})");
1125            let _ = writeln!(out, "        }}");
1126            let _ = writeln!(out, "        client.close()");
1127            let _ = writeln!(out, "    }}");
1128            return;
1129        }
1130        let _ = writeln!(out, "        val {result_var} = client.{function_name}({args_str})");
1131        if !collect_snippet.is_empty() {
1132            let _ = writeln!(out, "        {collect_snippet}");
1133        }
1134        for assertion in &fixture.assertions {
1135            render_assertion(
1136                out,
1137                assertion,
1138                result_var,
1139                class_name,
1140                field_resolver,
1141                result_is_simple,
1142                result_is_option,
1143                enum_fields,
1144                &e2e_config.fields_c_types,
1145                is_streaming,
1146            );
1147        }
1148        let _ = writeln!(out, "        client.close()");
1149        let _ = writeln!(out, "    }}");
1150        return;
1151    }
1152
1153    // Flat-function call style (no client_factory).
1154    if expects_error {
1155        // Wrap setup + call in assertFailsWith so validation errors thrown
1156        // during engine creation are also caught (mirrors Java's assertThrows).
1157        let _ = writeln!(out, "        assertFailsWith<Exception> {{");
1158        for line in &setup_lines {
1159            let _ = writeln!(out, "            {line}");
1160        }
1161        let _ = writeln!(out, "            {class_name}.{function_name}({args_str})");
1162        let _ = writeln!(out, "        }}");
1163        let _ = writeln!(out, "    }}");
1164        return;
1165    }
1166
1167    for line in &setup_lines {
1168        let _ = writeln!(out, "        {line}");
1169    }
1170
1171    let _ = writeln!(
1172        out,
1173        "        val {result_var} = {class_name}.{function_name}({args_str})"
1174    );
1175
1176    if !collect_snippet.is_empty() {
1177        let _ = writeln!(out, "        {collect_snippet}");
1178    }
1179
1180    for assertion in &fixture.assertions {
1181        render_assertion(
1182            out,
1183            assertion,
1184            result_var,
1185            class_name,
1186            field_resolver,
1187            result_is_simple,
1188            result_is_option,
1189            enum_fields,
1190            &e2e_config.fields_c_types,
1191            is_streaming,
1192        );
1193    }
1194
1195    let _ = writeln!(out, "    }}");
1196}
1197
1198/// Build setup lines and the argument list for the function call.
1199///
1200/// Returns `(setup_lines, args_string)`.
1201fn build_args_and_setup(
1202    fixture: &Fixture,
1203    input: &serde_json::Value,
1204    args: &[crate::config::ArgMapping],
1205    class_name: &str,
1206    options_type: Option<&str>,
1207    fixture_id: &str,
1208) -> (Vec<String>, String) {
1209    if args.is_empty() {
1210        return (Vec::new(), String::new());
1211    }
1212
1213    let mut setup_lines: Vec<String> = Vec::new();
1214    let mut parts: Vec<String> = Vec::new();
1215
1216    for arg in args {
1217        if arg.arg_type == "mock_url" {
1218            if fixture.has_host_root_route() {
1219                setup_lines.push(format!(
1220                    "val {} = System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\")",
1221                    arg.name,
1222                ));
1223            } else {
1224                setup_lines.push(format!(
1225                    "val {} = System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\"",
1226                    arg.name,
1227                ));
1228            }
1229            parts.push(arg.name.clone());
1230            continue;
1231        }
1232
1233        if arg.arg_type == "handle" {
1234            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1235            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1236            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1237            if config_value.is_null()
1238                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1239            {
1240                setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1241            } else {
1242                let json_str = serde_json::to_string(config_value).unwrap_or_default();
1243                let name = &arg.name;
1244                setup_lines.push(format!(
1245                    "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1246                    escape_kotlin(&json_str),
1247                ));
1248                setup_lines.push(format!(
1249                    "val {} = {class_name}.{constructor_name}({name}Config)",
1250                    arg.name,
1251                    name = name,
1252                ));
1253            }
1254            parts.push(arg.name.clone());
1255            continue;
1256        }
1257
1258        // Use resolve_field so field = "input" resolves to the whole fixture input.
1259        let val_resolved = super::resolve_field(input, &arg.field);
1260        let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1261            None
1262        } else {
1263            Some(val_resolved)
1264        };
1265        match val {
1266            None | Some(serde_json::Value::Null) if arg.optional => {
1267                // Optional arg with no fixture value: emit positional default so the
1268                // call has the right arity for the Java facade. For json_object
1269                // optional args with a configured options_type, construct an empty
1270                // default builder instead of passing raw null.
1271                if arg.arg_type == "json_object" {
1272                    if let Some(opts_type) = options_type {
1273                        parts.push(format!("{opts_type}.builder().build()"));
1274                    } else {
1275                        parts.push("null".to_string());
1276                    }
1277                } else {
1278                    parts.push("null".to_string());
1279                }
1280            }
1281            None | Some(serde_json::Value::Null) => {
1282                let default_val = match arg.arg_type.as_str() {
1283                    "string" => "\"\"".to_string(),
1284                    "int" | "integer" => "0".to_string(),
1285                    "float" | "number" => "0.0".to_string(),
1286                    "bool" | "boolean" => "false".to_string(),
1287                    _ => "null".to_string(),
1288                };
1289                parts.push(default_val);
1290            }
1291            Some(v) => {
1292                // Typed arrays carry `element_type`. Batch item arrays
1293                // (BatchBytesItem/BatchFileItem) need typed constructors; all
1294                // other typed lists (e.g. List<String>) are materialised as a
1295                // plain `listOf(...)` of the JSON literals.
1296                if arg.arg_type == "json_object" && v.is_array() {
1297                    if let Some(elem) = &arg.element_type {
1298                        if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1299                            parts.push(emit_kotlin_batch_item_array(v, elem));
1300                            continue;
1301                        }
1302                        // Generic typed list — emit literal Kotlin `listOf(...)`.
1303                        let items: Vec<String> = v
1304                            .as_array()
1305                            .map(|arr| arr.iter().map(json_to_kotlin).collect())
1306                            .unwrap_or_default();
1307                        parts.push(format!("listOf({})", items.join(", ")));
1308                        continue;
1309                    }
1310                }
1311                // For json_object args with options_type, use the pre-deserialized variable.
1312                if arg.arg_type == "json_object" && options_type.is_some() {
1313                    parts.push(arg.name.clone());
1314                    continue;
1315                }
1316                // bytes args carry a relative file path (e.g. "docx/fake.docx") that the
1317                // e2e harness resolves against test_documents/. Read the file at runtime
1318                // instead of converting the path string to its UTF-8 bytes.
1319                if arg.arg_type == "bytes" {
1320                    let val = json_to_kotlin(v);
1321                    parts.push(format!(
1322                        "java.nio.file.Files.readAllBytes(java.nio.file.Path.of({val}))"
1323                    ));
1324                    continue;
1325                }
1326                // file_path args must be wrapped in java.nio.file.Path.of(),
1327                // since the Kotlin module re-exports the Java facade signatures
1328                // which take Path rather than String for file-path parameters.
1329                if arg.arg_type == "file_path" {
1330                    let val = json_to_kotlin(v);
1331                    parts.push(format!("java.nio.file.Path.of({val})"));
1332                    continue;
1333                }
1334                parts.push(json_to_kotlin(v));
1335            }
1336        }
1337    }
1338
1339    (setup_lines, parts.join(", "))
1340}
1341
1342/// Emit a Kotlin `listOf(...)` expression of `BatchBytesItem` or
1343/// `BatchFileItem` constructors. Mirrors `emit_java_batch_item_array` so the
1344/// Kotlin tests build the same typed lists the Java facade expects.
1345fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1346    let Some(items) = arr.as_array() else {
1347        return "emptyList()".to_string();
1348    };
1349    let parts: Vec<String> = items
1350        .iter()
1351        .filter_map(|item| {
1352            let obj = item.as_object()?;
1353            match elem_type {
1354                "BatchBytesItem" => {
1355                    let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1356                    let content_code = obj
1357                        .get("content")
1358                        .and_then(|v| v.as_array())
1359                        .map(|arr| {
1360                            let bytes: Vec<String> =
1361                                arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1362                            format!("byteArrayOf({})", bytes.join(", "))
1363                        })
1364                        .unwrap_or_else(|| "byteArrayOf()".to_string());
1365                    Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1366                }
1367                "BatchFileItem" => {
1368                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1369                    Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1370                }
1371                _ => None,
1372            }
1373        })
1374        .collect();
1375    format!("listOf({})", parts.join(", "))
1376}
1377
1378#[allow(clippy::too_many_arguments)]
1379fn render_assertion(
1380    out: &mut String,
1381    assertion: &Assertion,
1382    result_var: &str,
1383    _class_name: &str,
1384    field_resolver: &FieldResolver,
1385    result_is_simple: bool,
1386    result_is_option: bool,
1387    enum_fields: &HashSet<String>,
1388    fields_c_types: &std::collections::HashMap<String, String>,
1389    is_streaming: bool,
1390) {
1391    // In streaming context, `usage` and `usage.*` fields must be read from the
1392    // last collected chunk, not from the stream iterator (which has no `usage()` method).
1393    // Route them through `StreamingFieldResolver::accessor("usage", ...)` + deep-tail
1394    // rendering, using `chunks.last().usage()` as the base expression.
1395    if is_streaming {
1396        if let Some(f) = &assertion.field {
1397            if f == "usage" || f.starts_with("usage.") {
1398                let base_expr =
1399                    crate::codegen::streaming_assertions::StreamingFieldResolver::accessor("usage", "kotlin", "chunks")
1400                        .unwrap_or_else(|| "(if (chunks.isEmpty()) null else chunks.last().usage())".to_string());
1401
1402                // For a deep path like `usage.total_tokens`, render the tail `.total_tokens`
1403                // in a Kotlin-idiomatic style (safe-call + camelCase method).
1404                let expr = if let Some(tail) = f.strip_prefix("usage.") {
1405                    use heck::ToLowerCamelCase;
1406                    // Each segment in the tail is a field accessor using `?.` (nullable base).
1407                    tail.split('.')
1408                        .fold(base_expr, |acc, seg| format!("{acc}?.{}()", seg.to_lower_camel_case()))
1409                } else {
1410                    base_expr
1411                };
1412
1413                // Determine if the field maps to a 64-bit C type requiring `L` suffix.
1414                let field_is_long = fields_c_types
1415                    .get(f.as_str())
1416                    .is_some_and(|t| matches!(t.as_str(), "uint64_t" | "int64_t"));
1417
1418                let line = match assertion.assertion_type.as_str() {
1419                    "equals" => {
1420                        if let Some(expected) = &assertion.value {
1421                            let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1422                                format!("{}L", expected)
1423                            } else {
1424                                json_to_kotlin(expected)
1425                            };
1426                            format!("        assertEquals({kotlin_val}, {expr}!!)\n")
1427                        } else {
1428                            String::new()
1429                        }
1430                    }
1431                    _ => String::new(),
1432                };
1433                if !line.is_empty() {
1434                    out.push_str(&line);
1435                }
1436                return;
1437            }
1438        }
1439    }
1440
1441    // Streaming virtual fields resolve against the `chunks` collected-list variable.
1442    // Intercept before is_valid_for_result so they are never skipped.
1443    if let Some(f) = &assertion.field {
1444        if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1445            if let Some(expr) =
1446                crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "kotlin", "chunks")
1447            {
1448                let line = match assertion.assertion_type.as_str() {
1449                    "count_min" => {
1450                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1451                            format!("        assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1452                        } else {
1453                            String::new()
1454                        }
1455                    }
1456                    "count_equals" => {
1457                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1458                            format!(
1459                                "        assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1460                            )
1461                        } else {
1462                            String::new()
1463                        }
1464                    }
1465                    "equals" => {
1466                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1467                            let escaped = escape_kotlin(s);
1468                            format!("        assertEquals(\"{escaped}\", {expr})\n")
1469                        } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1470                            format!("        assertEquals({b}, {expr})\n")
1471                        } else {
1472                            String::new()
1473                        }
1474                    }
1475                    "not_empty" => {
1476                        format!("        assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1477                    }
1478                    "is_empty" => {
1479                        format!("        assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1480                    }
1481                    "is_true" => {
1482                        format!("        assertTrue({expr}, \"expected true\")\n")
1483                    }
1484                    "is_false" => {
1485                        format!("        assertFalse({expr}, \"expected false\")\n")
1486                    }
1487                    "greater_than" => {
1488                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1489                            format!("        assertTrue({expr} > {n}, \"expected > {n}\")\n")
1490                        } else {
1491                            String::new()
1492                        }
1493                    }
1494                    "contains" => {
1495                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1496                            let escaped = escape_kotlin(s);
1497                            format!(
1498                                "        assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1499                            )
1500                        } else {
1501                            String::new()
1502                        }
1503                    }
1504                    _ => format!(
1505                        "        // streaming field '{f}': assertion type '{}' not rendered\n",
1506                        assertion.assertion_type
1507                    ),
1508                };
1509                if !line.is_empty() {
1510                    out.push_str(&line);
1511                }
1512            }
1513            return;
1514        }
1515    }
1516
1517    // Skip assertions on fields that don't exist on the result type.
1518    if let Some(f) = &assertion.field {
1519        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1520            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1521            return;
1522        }
1523    }
1524
1525    // Determine if this field is an enum type.
1526    let field_is_enum = assertion
1527        .field
1528        .as_deref()
1529        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1530
1531    // Raw field accessor — may end with nullable type if field is optional.
1532    let field_expr = if result_is_simple {
1533        result_var.to_string()
1534    } else {
1535        match &assertion.field {
1536            Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1537            _ => result_var.to_string(),
1538        }
1539    };
1540
1541    // Whether the accessor may return a nullable type in Kotlin. This is true
1542    // when the leaf field OR any intermediate segment in the path is optional
1543    // (the `?.` safe-call propagates null through the whole chain).
1544    //
1545    // Additionally, if the generated accessor expression itself contains `?.`
1546    // then the return type is `T?` regardless of what the path-resolver says —
1547    // sticky nullability means any `?.` in the chain makes the whole expression
1548    // nullable. This handles cases like `toolCalls()?.first()?.function()?.name()`
1549    // where the `is_optional` prefix lookup misses due to index notation mismatch.
1550    let field_is_optional = !result_is_simple
1551        && (field_expr.contains("?.")
1552            || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1553                let resolved = field_resolver.resolve(f);
1554                if field_resolver.has_map_access(f) {
1555                    return false;
1556                }
1557                // Check the leaf field itself.
1558                if field_resolver.is_optional(resolved) {
1559                    return true;
1560                }
1561                // Also check every prefix segment: if any intermediate field is
1562                // optional the ?.  chain propagates null to the final result.
1563                let mut prefix = String::new();
1564                for part in resolved.split('.') {
1565                    // Strip array notation for the lookup key.
1566                    let key = part.split('[').next().unwrap_or(part);
1567                    if !prefix.is_empty() {
1568                        prefix.push('.');
1569                    }
1570                    prefix.push_str(key);
1571                    if field_resolver.is_optional(&prefix) {
1572                        return true;
1573                    }
1574                }
1575                false
1576            }));
1577
1578    // String-context expression: append .orEmpty() for nullable string fields so
1579    // string operations (contains, trim) don't require a safe-call chain.
1580    // Note: this is only sound when the leaf type is `String?`. For enum-typed
1581    // optional fields (`T?` where `T` is an enum class), `.orEmpty()` is undefined;
1582    // the enum branch below handles those by going through `?.getValue()` first.
1583    let string_field_expr = if field_is_optional {
1584        format!("{field_expr}.orEmpty()")
1585    } else {
1586        field_expr.clone()
1587    };
1588
1589    // Non-null expression: use !! to assert presence for numeric comparisons where
1590    // the fixture guarantees the value is non-null.
1591    let nonnull_field_expr = if field_is_optional {
1592        format!("{field_expr}!!")
1593    } else {
1594        field_expr.clone()
1595    };
1596
1597    // For enum fields, use .getValue() to get the string value. When the enum
1598    // field (or any intermediate segment in its path) is optional, use a safe
1599    // call before `.getValue()` and then `.orEmpty()` to coerce `String?` back
1600    // to `String` — this matches the Java codegen's
1601    // `Optional.ofNullable(...).map(v -> v.getValue()).orElse("")` pattern.
1602    let string_expr = match (field_is_enum, field_is_optional) {
1603        (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1604        (true, false) => format!("{field_expr}.getValue()"),
1605        (false, _) => string_field_expr.clone(),
1606    };
1607
1608    // Determine if this assertion field maps to a 64-bit C type (uint64_t / int64_t),
1609    // which corresponds to Kotlin `Long`. When true, integer literals must be suffixed
1610    // with `L` to avoid a type mismatch between Kotlin `Int` and `Long`.
1611    let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1612        let resolved = field_resolver.resolve(f);
1613        matches!(
1614            fields_c_types.get(resolved).map(String::as_str),
1615            Some("uint64_t") | Some("int64_t")
1616        )
1617    });
1618
1619    match assertion.assertion_type.as_str() {
1620        "equals" => {
1621            if let Some(expected) = &assertion.value {
1622                // Suffix integer literals with `L` when the target field is a Java `long`
1623                // (uint64_t / int64_t in C FFI terms). Without the suffix, Kotlin infers
1624                // the literal as `Int`, causing a type mismatch with `Long` at runtime.
1625                let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1626                    format!("{}L", expected)
1627                } else {
1628                    json_to_kotlin(expected)
1629                };
1630                if expected.is_string() {
1631                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {string_expr}.trim())");
1632                } else {
1633                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {nonnull_field_expr})");
1634                }
1635            }
1636        }
1637        "contains" => {
1638            if let Some(expected) = &assertion.value {
1639                let kotlin_val = json_to_kotlin(expected);
1640                let _ = writeln!(
1641                    out,
1642                    "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1643                );
1644            }
1645        }
1646        "contains_all" => {
1647            if let Some(values) = &assertion.values {
1648                for val in values {
1649                    let kotlin_val = json_to_kotlin(val);
1650                    let _ = writeln!(
1651                        out,
1652                        "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1653                    );
1654                }
1655            }
1656        }
1657        "not_contains" => {
1658            if let Some(expected) = &assertion.value {
1659                let kotlin_val = json_to_kotlin(expected);
1660                let _ = writeln!(
1661                    out,
1662                    "        assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1663                );
1664            }
1665        }
1666        "not_empty" => {
1667            // For optional fields, the field type may be a non-String object
1668            // (e.g. DocumentStructure) for which `.orEmpty()` is undefined. A
1669            // null-check is the safe primitive: it works for any reference type
1670            // and matches the Java codegen's `Optional.ofNullable(...).isEmpty()`.
1671            // When the bare result is `T?` (result_is_option) the same null-check
1672            // applies, because `.isEmpty()` is undefined on arbitrary nullable types.
1673            // The Kotlin e2e tests call the Java facade class which returns
1674            // `java.util.Optional<T>` for option results — use `.isPresent` rather
1675            // than `!= null` so the assertion semantics match the JVM return type.
1676            let bare_result_is_option =
1677                result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1678            if bare_result_is_option {
1679                let _ = writeln!(
1680                    out,
1681                    "        assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
1682                );
1683            } else if field_is_optional {
1684                let _ = writeln!(
1685                    out,
1686                    "        assertTrue({field_expr} != null, \"expected non-empty value\")"
1687                );
1688            } else {
1689                let _ = writeln!(
1690                    out,
1691                    "        assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1692                );
1693            }
1694        }
1695        "is_empty" => {
1696            let bare_result_is_option =
1697                result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1698            if bare_result_is_option {
1699                let _ = writeln!(
1700                    out,
1701                    "        assertTrue({field_expr}.isEmpty, \"expected empty value\")"
1702                );
1703            } else if field_is_optional {
1704                let _ = writeln!(
1705                    out,
1706                    "        assertTrue({field_expr} == null, \"expected empty value\")"
1707                );
1708            } else {
1709                let _ = writeln!(
1710                    out,
1711                    "        assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1712                );
1713            }
1714        }
1715        "contains_any" => {
1716            if let Some(values) = &assertion.values {
1717                let checks: Vec<String> = values
1718                    .iter()
1719                    .map(|v| {
1720                        let kotlin_val = json_to_kotlin(v);
1721                        format!("{string_expr}.contains({kotlin_val})")
1722                    })
1723                    .collect();
1724                let joined = checks.join(" || ");
1725                let _ = writeln!(
1726                    out,
1727                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1728                );
1729            }
1730        }
1731        "greater_than" => {
1732            if let Some(val) = &assertion.value {
1733                let kotlin_val = json_to_kotlin(val);
1734                let _ = writeln!(
1735                    out,
1736                    "        assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
1737                );
1738            }
1739        }
1740        "less_than" => {
1741            if let Some(val) = &assertion.value {
1742                let kotlin_val = json_to_kotlin(val);
1743                let _ = writeln!(
1744                    out,
1745                    "        assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
1746                );
1747            }
1748        }
1749        "greater_than_or_equal" => {
1750            if let Some(val) = &assertion.value {
1751                let kotlin_val = json_to_kotlin(val);
1752                let _ = writeln!(
1753                    out,
1754                    "        assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
1755                );
1756            }
1757        }
1758        "less_than_or_equal" => {
1759            if let Some(val) = &assertion.value {
1760                let kotlin_val = json_to_kotlin(val);
1761                let _ = writeln!(
1762                    out,
1763                    "        assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
1764                );
1765            }
1766        }
1767        "starts_with" => {
1768            if let Some(expected) = &assertion.value {
1769                let kotlin_val = json_to_kotlin(expected);
1770                let _ = writeln!(
1771                    out,
1772                    "        assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1773                );
1774            }
1775        }
1776        "ends_with" => {
1777            if let Some(expected) = &assertion.value {
1778                let kotlin_val = json_to_kotlin(expected);
1779                let _ = writeln!(
1780                    out,
1781                    "        assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1782                );
1783            }
1784        }
1785        "min_length" => {
1786            if let Some(val) = &assertion.value {
1787                if let Some(n) = val.as_u64() {
1788                    let _ = writeln!(
1789                        out,
1790                        "        assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1791                    );
1792                }
1793            }
1794        }
1795        "max_length" => {
1796            if let Some(val) = &assertion.value {
1797                if let Some(n) = val.as_u64() {
1798                    let _ = writeln!(
1799                        out,
1800                        "        assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1801                    );
1802                }
1803            }
1804        }
1805        "count_min" => {
1806            if let Some(val) = &assertion.value {
1807                if let Some(n) = val.as_u64() {
1808                    let _ = writeln!(
1809                        out,
1810                        "        assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1811                    );
1812                }
1813            }
1814        }
1815        "count_equals" => {
1816            if let Some(val) = &assertion.value {
1817                if let Some(n) = val.as_u64() {
1818                    let _ = writeln!(
1819                        out,
1820                        "        assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1821                    );
1822                }
1823            }
1824        }
1825        "is_true" => {
1826            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\")");
1827        }
1828        "is_false" => {
1829            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\")");
1830        }
1831        "matches_regex" => {
1832            if let Some(expected) = &assertion.value {
1833                let kotlin_val = json_to_kotlin(expected);
1834                let _ = writeln!(
1835                    out,
1836                    "        assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1837                );
1838            }
1839        }
1840        "not_error" => {
1841            // Already handled by the call succeeding without exception.
1842        }
1843        "error" => {
1844            // Handled at the test method level.
1845        }
1846        "method_result" => {
1847            // Placeholder: Kotlin support for method_result would need tree-sitter integration.
1848            let _ = writeln!(
1849                out,
1850                "        // method_result assertions not yet implemented for Kotlin"
1851            );
1852        }
1853        other => {
1854            panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1855        }
1856    }
1857}
1858
1859/// Convert a `serde_json::Value` to a Kotlin literal string.
1860fn json_to_kotlin(value: &serde_json::Value) -> String {
1861    match value {
1862        serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1863        serde_json::Value::Bool(b) => b.to_string(),
1864        serde_json::Value::Number(n) => {
1865            if n.is_f64() {
1866                // Kotlin Double literals use no suffix (or `.0` if integer-shaped).
1867                // `0.9d` would parse as identifier `d` following a malformed literal.
1868                let s = n.to_string();
1869                if s.contains('.') || s.contains('e') || s.contains('E') {
1870                    s
1871                } else {
1872                    format!("{s}.0")
1873                }
1874            } else {
1875                n.to_string()
1876            }
1877        }
1878        serde_json::Value::Null => "null".to_string(),
1879        serde_json::Value::Array(arr) => {
1880            let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1881            format!("listOf({})", items.join(", "))
1882        }
1883        serde_json::Value::Object(_) => {
1884            let json_str = serde_json::to_string(value).unwrap_or_default();
1885            format!("\"{}\"", escape_kotlin(&json_str))
1886        }
1887    }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892    use super::*;
1893    use std::collections::HashMap;
1894
1895    fn make_resolver_for_finish_reason() -> FieldResolver {
1896        // Resolver for `choices[0].finish_reason` where:
1897        //   - `choices` is a registered array field (default index 0)
1898        //   - `choices.finish_reason` is optional (`@Nullable`)
1899        let mut optional = HashSet::new();
1900        optional.insert("choices.finish_reason".to_string());
1901        let mut arrays = HashSet::new();
1902        arrays.insert("choices".to_string());
1903        FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
1904    }
1905
1906    /// Regression: enum-typed optional fields must route through `?.getValue()`
1907    /// before falling back via `.orEmpty()`. Emitting `.orEmpty().getValue()`
1908    /// is invalid Kotlin because `T?.orEmpty()` is only defined for `String?`.
1909    #[test]
1910    fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
1911        let resolver = make_resolver_for_finish_reason();
1912        let mut enum_fields = HashSet::new();
1913        enum_fields.insert("choices.finish_reason".to_string());
1914        let assertion = Assertion {
1915            assertion_type: "equals".to_string(),
1916            field: Some("choices.finish_reason".to_string()),
1917            value: Some(serde_json::Value::String("stop".to_string())),
1918            values: None,
1919            method: None,
1920            check: None,
1921            args: None,
1922            return_type: None,
1923        };
1924        let mut out = String::new();
1925        render_assertion(
1926            &mut out,
1927            &assertion,
1928            "result",
1929            "",
1930            &resolver,
1931            false,
1932            false,
1933            &enum_fields,
1934            &HashMap::new(),
1935            false,
1936        );
1937        assert!(
1938            out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
1939            "expected enum-optional safe-call pattern, got: {out}"
1940        );
1941        assert!(
1942            !out.contains(".finishReason().orEmpty().getValue()"),
1943            "must not emit .orEmpty().getValue() on a nullable enum: {out}"
1944        );
1945    }
1946
1947    /// Non-optional enum field should call `.getValue()` directly without
1948    /// safe-call or fallback (no need to handle null).
1949    #[test]
1950    fn assertion_enum_non_optional_uses_plain_get_value() {
1951        let mut arrays = HashSet::new();
1952        arrays.insert("choices".to_string());
1953        let resolver = FieldResolver::new(
1954            &HashMap::new(),
1955            &HashSet::new(),
1956            &HashSet::new(),
1957            &arrays,
1958            &HashSet::new(),
1959        );
1960        let mut enum_fields = HashSet::new();
1961        enum_fields.insert("choices.finish_reason".to_string());
1962        let assertion = Assertion {
1963            assertion_type: "equals".to_string(),
1964            field: Some("choices.finish_reason".to_string()),
1965            value: Some(serde_json::Value::String("stop".to_string())),
1966            values: None,
1967            method: None,
1968            check: None,
1969            args: None,
1970            return_type: None,
1971        };
1972        let mut out = String::new();
1973        render_assertion(
1974            &mut out,
1975            &assertion,
1976            "result",
1977            "",
1978            &resolver,
1979            false,
1980            false,
1981            &enum_fields,
1982            &HashMap::new(),
1983            false,
1984        );
1985        assert!(
1986            out.contains("result.choices().first().finishReason().getValue().trim()"),
1987            "expected plain .getValue() for non-optional enum, got: {out}"
1988        );
1989    }
1990
1991    /// Regression: per-call `enum_fields` overrides (e.g. `status = "BatchStatus"`) must be
1992    /// merged into the effective enum-field set before rendering assertions.  Previously the
1993    /// kotlin codegen only consulted the global `fields_enum` set, so `status` on `BatchObject`
1994    /// was treated as a plain `String` and `.trim()` was emitted directly instead of
1995    /// `.getValue().trim()`, causing a Kotlin compile error ("BatchStatus has no method trim").
1996    #[test]
1997    fn per_call_enum_field_override_routes_through_get_value() {
1998        // Simulate `status` field on a non-optional result with no global enum registration.
1999        let resolver = FieldResolver::new(
2000            &HashMap::new(),
2001            &HashSet::new(),
2002            &HashSet::new(),
2003            &HashSet::new(),
2004            &HashSet::new(),
2005        );
2006        // `status` is NOT in the global enum_fields set...
2007        let global_enum_fields: HashSet<String> = HashSet::new();
2008        // ...but a per-call override registers it.
2009        let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
2010        per_call_enum_fields.insert("status".to_string());
2011
2012        let assertion = Assertion {
2013            assertion_type: "equals".to_string(),
2014            field: Some("status".to_string()),
2015            value: Some(serde_json::Value::String("validating".to_string())),
2016            values: None,
2017            method: None,
2018            check: None,
2019            args: None,
2020            return_type: None,
2021        };
2022
2023        // Without the merge (global only): must NOT emit .getValue()
2024        let mut out_no_merge = String::new();
2025        render_assertion(
2026            &mut out_no_merge,
2027            &assertion,
2028            "result",
2029            "",
2030            &resolver,
2031            false,
2032            false,
2033            &global_enum_fields,
2034            &HashMap::new(),
2035            false,
2036        );
2037        assert!(
2038            !out_no_merge.contains(".getValue()"),
2039            "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
2040        );
2041
2042        // With the merge (per-call included): must emit .getValue()
2043        let mut out_merged = String::new();
2044        render_assertion(
2045            &mut out_merged,
2046            &assertion,
2047            "result",
2048            "",
2049            &resolver,
2050            false,
2051            false,
2052            &per_call_enum_fields,
2053            &HashMap::new(),
2054            false,
2055        );
2056        assert!(
2057            out_merged.contains(".getValue()"),
2058            "merged per-call set must emit .getValue() for status: {out_merged}"
2059        );
2060    }
2061
2062    /// Auto-detection: fields whose Rust type is `Named(T)` where `T` is NOT a
2063    /// known struct should be treated as enum-typed without any explicit per-call
2064    /// `enum_fields` override. The `type_enum_fields` map (built in `generate()`)
2065    /// pre-computes these sets so `render_test_method` can merge them.
2066    #[test]
2067    fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2068        use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2069
2070        // Simulate a `BatchObject` type with `status: BatchStatus` (Named, not a struct).
2071        let batch_object_def = TypeDef {
2072            name: "BatchObject".to_string(),
2073            rust_path: "liter_llm::BatchObject".to_string(),
2074            original_rust_path: String::new(),
2075            fields: vec![
2076                FieldDef {
2077                    name: "id".to_string(),
2078                    ty: TypeRef::String,
2079                    optional: false,
2080                    default: None,
2081                    doc: String::new(),
2082                    sanitized: false,
2083                    is_boxed: false,
2084                    type_rust_path: None,
2085                    cfg: None,
2086                    typed_default: None,
2087                    core_wrapper: CoreWrapper::None,
2088                    vec_inner_core_wrapper: CoreWrapper::None,
2089                    newtype_wrapper: None,
2090                    serde_rename: None,
2091                    serde_flatten: false,
2092                },
2093                FieldDef {
2094                    name: "status".to_string(),
2095                    ty: TypeRef::Named("BatchStatus".to_string()),
2096                    optional: false,
2097                    default: None,
2098                    doc: String::new(),
2099                    sanitized: false,
2100                    is_boxed: false,
2101                    type_rust_path: None,
2102                    cfg: None,
2103                    typed_default: None,
2104                    core_wrapper: CoreWrapper::None,
2105                    vec_inner_core_wrapper: CoreWrapper::None,
2106                    newtype_wrapper: None,
2107                    serde_rename: None,
2108                    serde_flatten: false,
2109                },
2110            ],
2111            methods: vec![],
2112            is_opaque: false,
2113            is_clone: true,
2114            is_copy: false,
2115            doc: String::new(),
2116            cfg: None,
2117            is_trait: false,
2118            has_default: false,
2119            has_stripped_cfg_fields: false,
2120            is_return_type: true,
2121            serde_rename_all: None,
2122            has_serde: true,
2123            super_traits: vec![],
2124        };
2125
2126        // `BatchObject` is the only struct — `BatchStatus` is not in struct_names.
2127        let type_defs = [batch_object_def];
2128        let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2129
2130        // Verify is_enum_typed correctly identifies `status` as enum-typed.
2131        let status_ty = TypeRef::Named("BatchStatus".to_string());
2132        assert!(
2133            is_enum_typed(&status_ty, &struct_names),
2134            "BatchStatus (not a known struct) should be detected as enum-typed"
2135        );
2136        let id_ty = TypeRef::String;
2137        assert!(
2138            !is_enum_typed(&id_ty, &struct_names),
2139            "String field should NOT be detected as enum-typed"
2140        );
2141
2142        // Verify the type_enum_fields map is built correctly.
2143        let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2144            .iter()
2145            .filter_map(|td| {
2146                let enum_field_names: HashSet<String> = td
2147                    .fields
2148                    .iter()
2149                    .filter(|field| is_enum_typed(&field.ty, &struct_names))
2150                    .map(|field| field.name.clone())
2151                    .collect();
2152                if enum_field_names.is_empty() {
2153                    None
2154                } else {
2155                    Some((td.name.clone(), enum_field_names))
2156                }
2157            })
2158            .collect();
2159
2160        let batch_enum_fields = type_enum_fields
2161            .get("BatchObject")
2162            .expect("BatchObject should have enum fields");
2163        assert!(
2164            batch_enum_fields.contains("status"),
2165            "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2166        );
2167        assert!(
2168            !batch_enum_fields.contains("id"),
2169            "BatchObject.id (String) must not be in enum fields"
2170        );
2171
2172        // Verify render_assertion produces `.getValue()` when `status` is in enum_fields.
2173        let resolver = FieldResolver::new(
2174            &HashMap::new(),
2175            &HashSet::new(),
2176            &HashSet::new(),
2177            &HashSet::new(),
2178            &HashSet::new(),
2179        );
2180        let assertion = Assertion {
2181            assertion_type: "equals".to_string(),
2182            field: Some("status".to_string()),
2183            value: Some(serde_json::Value::String("validating".to_string())),
2184            values: None,
2185            method: None,
2186            check: None,
2187            args: None,
2188            return_type: None,
2189        };
2190        let mut out = String::new();
2191        render_assertion(
2192            &mut out,
2193            &assertion,
2194            "result",
2195            "",
2196            &resolver,
2197            false,
2198            false,
2199            batch_enum_fields,
2200            &HashMap::new(),
2201            false,
2202        );
2203        assert!(
2204            out.contains(".getValue()"),
2205            "auto-detected enum field must route through .getValue(), got: {out}"
2206        );
2207    }
2208}