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        // Generate build.gradle.kts.
80        files.push(GeneratedFile {
81            path: output_base.join("build.gradle.kts"),
82            content: render_build_gradle(&pkg_name, &kotlin_pkg_id, &kotlin_version, e2e_config.dep_mode),
83            generated_header: false,
84        });
85
86        // Generate test files per category. Path mirrors the configured Kotlin
87        // package so the package declaration in each test file matches its
88        // filesystem location.
89        let mut test_base = output_base.join("src").join("test").join("kotlin");
90        for segment in kotlin_pkg_id.split('.') {
91            test_base = test_base.join(segment);
92        }
93        let test_base = test_base.join("e2e");
94
95        // Resolve options_type from override.
96        let options_type = overrides.and_then(|o| o.options_type.clone());
97        let field_resolver = FieldResolver::new(
98            &e2e_config.fields,
99            &e2e_config.fields_optional,
100            &e2e_config.result_fields,
101            &e2e_config.fields_array,
102            &HashSet::new(),
103        );
104
105        for group in groups {
106            let active: Vec<&Fixture> = group
107                .fixtures
108                .iter()
109                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
110                .collect();
111
112            if active.is_empty() {
113                continue;
114            }
115
116            let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
117            let content = render_test_file(
118                &group.category,
119                &active,
120                &class_name,
121                &function_name,
122                &kotlin_pkg_id,
123                result_var,
124                &e2e_config.call.args,
125                options_type.as_deref(),
126                &field_resolver,
127                result_is_simple,
128                &e2e_config.fields_enum,
129                e2e_config,
130            );
131            files.push(GeneratedFile {
132                path: test_base.join(class_file_name),
133                content,
134                generated_header: true,
135            });
136        }
137
138        Ok(files)
139    }
140
141    fn language_name(&self) -> &'static str {
142        "kotlin"
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Rendering
148// ---------------------------------------------------------------------------
149
150fn render_build_gradle(
151    pkg_name: &str,
152    kotlin_pkg_id: &str,
153    pkg_version: &str,
154    dep_mode: crate::config::DependencyMode,
155) -> String {
156    let dep_block = match dep_mode {
157        crate::config::DependencyMode::Registry => {
158            // Registry mode: maven central with group:artifact:version
159            format!(r#"    testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
160        }
161        crate::config::DependencyMode::Local => {
162            // Local mode: reference local JAR from kreuzberg binding
163            format!(r#"    testImplementation(files("../../target/release/{pkg_name}.jar"))"#)
164        }
165    };
166
167    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
168    let junit = maven::JUNIT;
169    let jackson = maven::JACKSON_E2E;
170    let jvm_target = toolchain::JVM_TARGET;
171    format!(
172        r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
173
174plugins {{
175    kotlin("jvm") version "{kotlin_plugin}"
176}}
177
178group = "{kotlin_pkg_id}"
179version = "0.1.0"
180
181java {{
182    sourceCompatibility = JavaVersion.VERSION_{jvm_target}
183    targetCompatibility = JavaVersion.VERSION_{jvm_target}
184}}
185
186kotlin {{
187    compilerOptions {{
188        jvmTarget.set(JvmTarget.JVM_{jvm_target})
189    }}
190}}
191
192repositories {{
193    mavenCentral()
194}}
195
196dependencies {{
197{dep_block}
198    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
199    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
200    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
201    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
202    testImplementation(kotlin("test"))
203}}
204
205tasks.test {{
206    useJUnitPlatform()
207    environment("java.library.path", "../../target/release")
208}}
209"#
210    )
211}
212
213#[allow(clippy::too_many_arguments)]
214fn render_test_file(
215    category: &str,
216    fixtures: &[&Fixture],
217    class_name: &str,
218    function_name: &str,
219    kotlin_pkg_id: &str,
220    result_var: &str,
221    args: &[crate::config::ArgMapping],
222    options_type: Option<&str>,
223    field_resolver: &FieldResolver,
224    result_is_simple: bool,
225    enum_fields: &HashSet<String>,
226    e2e_config: &E2eConfig,
227) -> String {
228    let mut out = String::new();
229    out.push_str(&hash::header(CommentStyle::DoubleSlash));
230    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
231
232    // If the class_name is fully qualified (contains '.'), import it and use
233    // only the simple name for method calls. Otherwise use it as-is.
234    let (import_path, simple_class) = if class_name.contains('.') {
235        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
236        (class_name, simple)
237    } else {
238        ("", class_name)
239    };
240
241    let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
242    let _ = writeln!(out);
243
244    // Detect if any fixture in this group is an HTTP server test.
245    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
246
247    // Check if any fixture uses a json_object arg with options_type (needs ObjectMapper).
248    let needs_object_mapper_for_options = options_type.is_some()
249        && fixtures.iter().any(|f| {
250            args.iter().any(|arg| {
251                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
252                arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
253            })
254        });
255    // Also need ObjectMapper when a handle arg has a non-null config.
256    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
257        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
258            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
259            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
260        })
261    });
262    // HTTP fixtures always need ObjectMapper for JSON body comparison.
263    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
264
265    let _ = writeln!(out, "import org.junit.jupiter.api.Test");
266    let _ = writeln!(out, "import kotlin.test.assertEquals");
267    let _ = writeln!(out, "import kotlin.test.assertTrue");
268    let _ = writeln!(out, "import kotlin.test.assertFalse");
269    let _ = writeln!(out, "import kotlin.test.assertFailsWith");
270    // Only import the binding class when there are non-HTTP fixtures that call it.
271    let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
272    if has_call_fixtures && !import_path.is_empty() {
273        let _ = writeln!(out, "import {import_path}");
274    }
275    if needs_object_mapper {
276        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
277        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
278    }
279    // Import the options type if tests use it (it's in the same package as the main class).
280    if let Some(opts_type) = options_type {
281        if needs_object_mapper && has_call_fixtures {
282            // Derive the fully-qualified name from the main class import path.
283            let opts_package = if !import_path.is_empty() {
284                let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
285                format!("{pkg}.{opts_type}")
286            } else {
287                opts_type.to_string()
288            };
289            let _ = writeln!(out, "import {opts_package}");
290        }
291    }
292    // Import CrawlConfig when handle args need JSON deserialization.
293    if needs_object_mapper_for_handle && !import_path.is_empty() {
294        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
295        let _ = writeln!(out, "import {pkg}.CrawlConfig");
296    }
297    let _ = writeln!(out);
298
299    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
300    let _ = writeln!(out, "class {test_class_name} {{");
301
302    if needs_object_mapper {
303        let _ = writeln!(out);
304        let _ = writeln!(out, "    companion object {{");
305        let _ = writeln!(
306            out,
307            "        private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
308        );
309        let _ = writeln!(out, "    }}");
310    }
311
312    for fixture in fixtures {
313        render_test_method(
314            &mut out,
315            fixture,
316            simple_class,
317            function_name,
318            result_var,
319            args,
320            options_type,
321            field_resolver,
322            result_is_simple,
323            enum_fields,
324            e2e_config,
325        );
326        let _ = writeln!(out);
327    }
328
329    let _ = writeln!(out, "}}");
330    out
331}
332
333// ---------------------------------------------------------------------------
334// HTTP server test rendering — TestClientRenderer impl + thin driver wrapper
335// ---------------------------------------------------------------------------
336
337/// Renderer that emits JUnit 5 `@Test fun testFoo()` blocks using
338/// `java.net.http.HttpClient` against `System.getenv("MOCK_SERVER_URL")`.
339struct KotlinTestClientRenderer;
340
341impl client::TestClientRenderer for KotlinTestClientRenderer {
342    fn language_name(&self) -> &'static str {
343        "kotlin"
344    }
345
346    fn sanitize_test_name(&self, id: &str) -> String {
347        sanitize_ident(id).to_upper_camel_case()
348    }
349
350    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
351        let _ = writeln!(out, "    @Test");
352        let _ = writeln!(out, "    fun test{fn_name}() {{");
353        let _ = writeln!(out, "        // {description}");
354        if let Some(reason) = skip_reason {
355            let escaped = escape_kotlin(reason);
356            let _ = writeln!(
357                out,
358                "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
359            );
360        }
361    }
362
363    fn render_test_close(&self, out: &mut String) {
364        let _ = writeln!(out, "    }}");
365    }
366
367    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
368        let method = ctx.method.to_uppercase();
369        let fixture_path = ctx.path;
370
371        // Java's HttpClient restricts certain headers that cannot be set programmatically.
372        const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
373
374        let _ = writeln!(
375            out,
376            "        val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
377        );
378        let _ = writeln!(out, "        val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
379
380        let body_publisher = if let Some(body) = ctx.body {
381            let json = serde_json::to_string(body).unwrap_or_default();
382            let escaped = escape_kotlin(&json);
383            format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
384        } else {
385            "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
386        };
387
388        let _ = writeln!(out, "        val builder = java.net.http.HttpRequest.newBuilder(uri)");
389        let _ = writeln!(out, "            .method(\"{method}\", {body_publisher})");
390
391        // Content-Type header when there is a body.
392        if ctx.body.is_some() {
393            let content_type = ctx.content_type.unwrap_or("application/json");
394            let _ = writeln!(out, "            .header(\"Content-Type\", \"{content_type}\")");
395        }
396
397        // Explicit request headers (sorted for deterministic output).
398        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
399        header_pairs.sort_by_key(|(k, _)| k.as_str());
400        for (name, value) in &header_pairs {
401            if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
402                continue;
403            }
404            let escaped_name = escape_kotlin(name);
405            let escaped_value = escape_kotlin(value);
406            let _ = writeln!(out, "            .header(\"{escaped_name}\", \"{escaped_value}\")");
407        }
408
409        // Cookies as a single Cookie header.
410        if !ctx.cookies.is_empty() {
411            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
412            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
413            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
414            let cookie_header = escape_kotlin(&cookie_str.join("; "));
415            let _ = writeln!(out, "            .header(\"Cookie\", \"{cookie_header}\")");
416        }
417
418        let _ = writeln!(
419            out,
420            "        val {} = java.net.http.HttpClient.newHttpClient()",
421            ctx.response_var
422        );
423        let _ = writeln!(
424            out,
425            "            .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
426        );
427    }
428
429    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
430        let _ = writeln!(
431            out,
432            "        assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
433        );
434    }
435
436    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
437        let escaped_name = escape_kotlin(name);
438        match expected {
439            "<<present>>" => {
440                let _ = writeln!(
441                    out,
442                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
443                );
444            }
445            "<<absent>>" => {
446                let _ = writeln!(
447                    out,
448                    "        assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
449                );
450            }
451            "<<uuid>>" => {
452                let _ = writeln!(
453                    out,
454                    "        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\")"
455                );
456            }
457            exact => {
458                let escaped_value = escape_kotlin(exact);
459                let _ = writeln!(
460                    out,
461                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
462                );
463            }
464        }
465    }
466
467    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
468        match expected {
469            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
470                let json_str = serde_json::to_string(expected).unwrap_or_default();
471                let escaped = escape_kotlin(&json_str);
472                let _ = writeln!(out, "        val bodyJson = MAPPER.readTree({response_var}.body())");
473                let _ = writeln!(out, "        val expectedJson = MAPPER.readTree(\"{escaped}\")");
474                let _ = writeln!(out, "        assertEquals(expectedJson, bodyJson, \"body mismatch\")");
475            }
476            serde_json::Value::String(s) => {
477                let escaped = escape_kotlin(s);
478                let _ = writeln!(
479                    out,
480                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
481                );
482            }
483            other => {
484                let escaped = escape_kotlin(&other.to_string());
485                let _ = writeln!(
486                    out,
487                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
488                );
489            }
490        }
491    }
492
493    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
494        if let Some(obj) = expected.as_object() {
495            let _ = writeln!(out, "        val _partialTree = MAPPER.readTree({response_var}.body())");
496            for (key, val) in obj {
497                let escaped_key = escape_kotlin(key);
498                match val {
499                    serde_json::Value::String(s) => {
500                        let escaped_val = escape_kotlin(s);
501                        let _ = writeln!(
502                            out,
503                            "        assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
504                        );
505                    }
506                    serde_json::Value::Bool(b) => {
507                        let _ = writeln!(
508                            out,
509                            "        assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
510                        );
511                    }
512                    serde_json::Value::Number(n) => {
513                        let _ = writeln!(
514                            out,
515                            "        assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
516                        );
517                    }
518                    other => {
519                        let json_str = serde_json::to_string(other).unwrap_or_default();
520                        let escaped_val = escape_kotlin(&json_str);
521                        let _ = writeln!(
522                            out,
523                            "        assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
524                        );
525                    }
526                }
527            }
528        }
529    }
530
531    fn render_assert_validation_errors(
532        &self,
533        out: &mut String,
534        response_var: &str,
535        errors: &[ValidationErrorExpectation],
536    ) {
537        let _ = writeln!(out, "        val _veTree = MAPPER.readTree({response_var}.body())");
538        let _ = writeln!(out, "        val _veErrors = _veTree.path(\"errors\")");
539        for ve in errors {
540            let escaped_msg = escape_kotlin(&ve.msg);
541            let _ = writeln!(
542                out,
543                "        assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
544            );
545        }
546    }
547}
548
549/// Render a JUnit 5 `@Test` method for an HTTP server fixture via the shared driver.
550///
551/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because Java's
552/// `HttpClient` cannot handle protocol-switch responses (throws `EOFException`).
553fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
554    // HTTP 101 (WebSocket upgrade) — java.net.http.HttpClient cannot handle upgrade responses.
555    if http.expected_response.status_code == 101 {
556        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
557        let description = &fixture.description;
558        let _ = writeln!(out, "    @Test");
559        let _ = writeln!(out, "    fun test{method_name}() {{");
560        let _ = writeln!(out, "        // {description}");
561        let _ = writeln!(
562            out,
563            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
564        );
565        let _ = writeln!(out, "    }}");
566        return;
567    }
568
569    client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
570}
571
572#[allow(clippy::too_many_arguments)]
573fn render_test_method(
574    out: &mut String,
575    fixture: &Fixture,
576    class_name: &str,
577    _function_name: &str,
578    _result_var: &str,
579    _args: &[crate::config::ArgMapping],
580    options_type: Option<&str>,
581    field_resolver: &FieldResolver,
582    result_is_simple: bool,
583    enum_fields: &HashSet<String>,
584    e2e_config: &E2eConfig,
585) {
586    // Delegate HTTP fixtures to the HTTP-specific renderer.
587    if let Some(http) = &fixture.http {
588        render_http_test_method(out, fixture, http);
589        return;
590    }
591
592    // Resolve per-fixture call config (supports named calls via fixture.call field).
593    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
594    let lang = "kotlin";
595    let call_overrides = call_config.overrides.get(lang);
596
597    // Emit a compilable stub for non-HTTP fixtures that have no Kotlin-specific call
598    // override — these fixtures call the default function (e.g., `handleRequest`) which
599    // may not exist in the Kotlin binding at this target (e.g., asyncapi, websocket).
600    if call_overrides.is_none() {
601        let method_name = fixture.id.to_upper_camel_case();
602        let description = &fixture.description;
603        let _ = writeln!(out, "    @Test");
604        let _ = writeln!(out, "    fun test{method_name}() {{");
605        let _ = writeln!(out, "        // {description}");
606        let _ = writeln!(
607            out,
608            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"TODO: implement Kotlin e2e test for fixture '{}'\")",
609            fixture.id
610        );
611        let _ = writeln!(out, "    }}");
612        return;
613    }
614    let effective_function_name = call_overrides
615        .and_then(|o| o.function.as_ref())
616        .cloned()
617        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
618    let effective_result_var = &call_config.result_var;
619    let effective_args = &call_config.args;
620    let function_name = effective_function_name.as_str();
621    let result_var = effective_result_var.as_str();
622    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
623
624    let method_name = fixture.id.to_upper_camel_case();
625    let description = &fixture.description;
626    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
627
628    // Check if this test needs ObjectMapper deserialization for json_object args.
629    let needs_deser = options_type.is_some()
630        && args
631            .iter()
632            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
633
634    let _ = writeln!(out, "    @Test");
635    let _ = writeln!(out, "    fun test{method_name}() {{");
636    let _ = writeln!(out, "        // {description}");
637
638    // Emit ObjectMapper deserialization bindings for json_object args.
639    if let (true, Some(opts_type)) = (needs_deser, options_type) {
640        for arg in args {
641            if arg.arg_type == "json_object" {
642                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
643                if let Some(val) = fixture.input.get(field) {
644                    if !val.is_null() {
645                        let normalized = super::normalize_json_keys_to_snake_case(val);
646                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
647                        let var_name = &arg.name;
648                        let _ = writeln!(
649                            out,
650                            "        val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
651                            escape_kotlin(&json_str)
652                        );
653                    }
654                }
655            }
656        }
657    }
658
659    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
660
661    for line in &setup_lines {
662        let _ = writeln!(out, "        {line}");
663    }
664
665    if expects_error {
666        let _ = writeln!(
667            out,
668            "        assertFailsWith<Exception> {{ {class_name}.{function_name}({args_str}) }}"
669        );
670        let _ = writeln!(out, "    }}");
671        return;
672    }
673
674    let _ = writeln!(
675        out,
676        "        val {result_var} = {class_name}.{function_name}({args_str})"
677    );
678
679    for assertion in &fixture.assertions {
680        render_assertion(
681            out,
682            assertion,
683            result_var,
684            class_name,
685            field_resolver,
686            result_is_simple,
687            enum_fields,
688        );
689    }
690
691    let _ = writeln!(out, "    }}");
692}
693
694/// Build setup lines and the argument list for the function call.
695///
696/// Returns `(setup_lines, args_string)`.
697fn build_args_and_setup(
698    input: &serde_json::Value,
699    args: &[crate::config::ArgMapping],
700    class_name: &str,
701    options_type: Option<&str>,
702    fixture_id: &str,
703) -> (Vec<String>, String) {
704    if args.is_empty() {
705        return (Vec::new(), String::new());
706    }
707
708    let mut setup_lines: Vec<String> = Vec::new();
709    let mut parts: Vec<String> = Vec::new();
710
711    for arg in args {
712        if arg.arg_type == "mock_url" {
713            setup_lines.push(format!(
714                "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
715                arg.name,
716            ));
717            parts.push(arg.name.clone());
718            continue;
719        }
720
721        if arg.arg_type == "handle" {
722            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
723            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
724            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
725            if config_value.is_null()
726                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
727            {
728                setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
729            } else {
730                let json_str = serde_json::to_string(config_value).unwrap_or_default();
731                let name = &arg.name;
732                setup_lines.push(format!(
733                    "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
734                    escape_kotlin(&json_str),
735                ));
736                setup_lines.push(format!(
737                    "val {} = {class_name}.{constructor_name}({name}Config)",
738                    arg.name,
739                    name = name,
740                ));
741            }
742            parts.push(arg.name.clone());
743            continue;
744        }
745
746        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
747        let val = input.get(field);
748        match val {
749            None | Some(serde_json::Value::Null) if arg.optional => {
750                continue;
751            }
752            None | Some(serde_json::Value::Null) => {
753                let default_val = match arg.arg_type.as_str() {
754                    "string" => "\"\"".to_string(),
755                    "int" | "integer" => "0".to_string(),
756                    "float" | "number" => "0.0".to_string(),
757                    "bool" | "boolean" => "false".to_string(),
758                    _ => "null".to_string(),
759                };
760                parts.push(default_val);
761            }
762            Some(v) => {
763                // For json_object args with options_type, use the pre-deserialized variable.
764                if arg.arg_type == "json_object" && options_type.is_some() {
765                    parts.push(arg.name.clone());
766                    continue;
767                }
768                // bytes args must be passed as ByteArray.
769                if arg.arg_type == "bytes" {
770                    let val = json_to_kotlin(v);
771                    parts.push(format!("{val}.toByteArray()"));
772                    continue;
773                }
774                parts.push(json_to_kotlin(v));
775            }
776        }
777    }
778
779    (setup_lines, parts.join(", "))
780}
781
782fn render_assertion(
783    out: &mut String,
784    assertion: &Assertion,
785    result_var: &str,
786    _class_name: &str,
787    field_resolver: &FieldResolver,
788    result_is_simple: bool,
789    enum_fields: &HashSet<String>,
790) {
791    // Skip assertions on fields that don't exist on the result type.
792    if let Some(f) = &assertion.field {
793        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
794            let _ = writeln!(out, "        // skipped: field '{{f}}' not available on result type");
795            return;
796        }
797    }
798
799    // Determine if this field is an enum type.
800    let field_is_enum = assertion
801        .field
802        .as_deref()
803        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
804
805    let field_expr = if result_is_simple {
806        result_var.to_string()
807    } else {
808        match &assertion.field {
809            Some(f) if !f.is_empty() => {
810                let accessor = field_resolver.accessor(f, "kotlin", result_var);
811                let resolved = field_resolver.resolve(f);
812                // In Kotlin, use .orEmpty() for Optional<String> fields.
813                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
814                    format!("{accessor}.orEmpty()")
815                } else {
816                    accessor
817                }
818            }
819            _ => result_var.to_string(),
820        }
821    };
822
823    // For enum fields, use .getValue() to get the string value.
824    let string_expr = if field_is_enum {
825        format!("{field_expr}.getValue()")
826    } else {
827        field_expr.clone()
828    };
829
830    match assertion.assertion_type.as_str() {
831        "equals" => {
832            if let Some(expected) = &assertion.value {
833                let kotlin_val = json_to_kotlin(expected);
834                if expected.is_string() {
835                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {string_expr}.trim())");
836                } else {
837                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {field_expr})");
838                }
839            }
840        }
841        "contains" => {
842            if let Some(expected) = &assertion.value {
843                let kotlin_val = json_to_kotlin(expected);
844                let _ = writeln!(
845                    out,
846                    "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
847                );
848            }
849        }
850        "contains_all" => {
851            if let Some(values) = &assertion.values {
852                for val in values {
853                    let kotlin_val = json_to_kotlin(val);
854                    let _ = writeln!(
855                        out,
856                        "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
857                    );
858                }
859            }
860        }
861        "not_contains" => {
862            if let Some(expected) = &assertion.value {
863                let kotlin_val = json_to_kotlin(expected);
864                let _ = writeln!(
865                    out,
866                    "        assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
867                );
868            }
869        }
870        "not_empty" => {
871            let _ = writeln!(
872                out,
873                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\")"
874            );
875        }
876        "is_empty" => {
877            let _ = writeln!(
878                out,
879                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\")"
880            );
881        }
882        "contains_any" => {
883            if let Some(values) = &assertion.values {
884                let checks: Vec<String> = values
885                    .iter()
886                    .map(|v| {
887                        let kotlin_val = json_to_kotlin(v);
888                        format!("{string_expr}.contains({kotlin_val})")
889                    })
890                    .collect();
891                let joined = checks.join(" || ");
892                let _ = writeln!(
893                    out,
894                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\")"
895                );
896            }
897        }
898        "greater_than" => {
899            if let Some(val) = &assertion.value {
900                let kotlin_val = json_to_kotlin(val);
901                let _ = writeln!(
902                    out,
903                    "        assertTrue({field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
904                );
905            }
906        }
907        "less_than" => {
908            if let Some(val) = &assertion.value {
909                let kotlin_val = json_to_kotlin(val);
910                let _ = writeln!(
911                    out,
912                    "        assertTrue({field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
913                );
914            }
915        }
916        "greater_than_or_equal" => {
917            if let Some(val) = &assertion.value {
918                let kotlin_val = json_to_kotlin(val);
919                let _ = writeln!(
920                    out,
921                    "        assertTrue({field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
922                );
923            }
924        }
925        "less_than_or_equal" => {
926            if let Some(val) = &assertion.value {
927                let kotlin_val = json_to_kotlin(val);
928                let _ = writeln!(
929                    out,
930                    "        assertTrue({field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
931                );
932            }
933        }
934        "starts_with" => {
935            if let Some(expected) = &assertion.value {
936                let kotlin_val = json_to_kotlin(expected);
937                let _ = writeln!(
938                    out,
939                    "        assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
940                );
941            }
942        }
943        "ends_with" => {
944            if let Some(expected) = &assertion.value {
945                let kotlin_val = json_to_kotlin(expected);
946                let _ = writeln!(
947                    out,
948                    "        assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
949                );
950            }
951        }
952        "min_length" => {
953            if let Some(val) = &assertion.value {
954                if let Some(n) = val.as_u64() {
955                    let _ = writeln!(
956                        out,
957                        "        assertTrue({field_expr}.length >= {n}, \"expected length >= {n}\")"
958                    );
959                }
960            }
961        }
962        "max_length" => {
963            if let Some(val) = &assertion.value {
964                if let Some(n) = val.as_u64() {
965                    let _ = writeln!(
966                        out,
967                        "        assertTrue({field_expr}.length <= {n}, \"expected length <= {n}\")"
968                    );
969                }
970            }
971        }
972        "count_min" => {
973            if let Some(val) = &assertion.value {
974                if let Some(n) = val.as_u64() {
975                    let _ = writeln!(
976                        out,
977                        "        assertTrue({field_expr}.size >= {n}, \"expected at least {n} elements\")"
978                    );
979                }
980            }
981        }
982        "count_equals" => {
983            if let Some(val) = &assertion.value {
984                if let Some(n) = val.as_u64() {
985                    let _ = writeln!(
986                        out,
987                        "        assertEquals({n}, {field_expr}.size, \"expected exactly {n} elements\")"
988                    );
989                }
990            }
991        }
992        "is_true" => {
993            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\")");
994        }
995        "is_false" => {
996            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\")");
997        }
998        "matches_regex" => {
999            if let Some(expected) = &assertion.value {
1000                let kotlin_val = json_to_kotlin(expected);
1001                let _ = writeln!(
1002                    out,
1003                    "        assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1004                );
1005            }
1006        }
1007        "not_error" => {
1008            // Already handled by the call succeeding without exception.
1009        }
1010        "error" => {
1011            // Handled at the test method level.
1012        }
1013        "method_result" => {
1014            // Placeholder: Kotlin support for method_result would need tree-sitter integration.
1015            let _ = writeln!(
1016                out,
1017                "        // method_result assertions not yet implemented for Kotlin"
1018            );
1019        }
1020        other => {
1021            panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1022        }
1023    }
1024}
1025
1026/// Convert a `serde_json::Value` to a Kotlin literal string.
1027fn json_to_kotlin(value: &serde_json::Value) -> String {
1028    match value {
1029        serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1030        serde_json::Value::Bool(b) => b.to_string(),
1031        serde_json::Value::Number(n) => {
1032            if n.is_f64() {
1033                format!("{}d", n)
1034            } else {
1035                n.to_string()
1036            }
1037        }
1038        serde_json::Value::Null => "null".to_string(),
1039        serde_json::Value::Array(arr) => {
1040            let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1041            format!("listOf({})", items.join(", "))
1042        }
1043        serde_json::Value::Object(_) => {
1044            let json_str = serde_json::to_string(value).unwrap_or_default();
1045            format!("\"{}\"", escape_kotlin(&json_str))
1046        }
1047    }
1048}