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