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_java, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
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;
21
22/// Kotlin e2e code generator.
23pub struct KotlinE2eCodegen;
24
25impl E2eCodegen for KotlinE2eCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let _module_path = overrides
41            .and_then(|o| o.module.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.module.clone());
44        let function_name = overrides
45            .and_then(|o| o.function.as_ref())
46            .cloned()
47            .unwrap_or_else(|| call.function.clone());
48        let class_name = overrides
49            .and_then(|o| o.class.as_ref())
50            .cloned()
51            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
52        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
53        let result_var = &call.result_var;
54
55        // Resolve package config.
56        let kotlin_pkg = e2e_config.resolve_package("kotlin");
57        let pkg_name = kotlin_pkg
58            .as_ref()
59            .and_then(|p| p.name.as_ref())
60            .cloned()
61            .unwrap_or_else(|| alef_config.crate_config.name.clone());
62
63        // Resolve Kotlin package for generated tests.
64        let _kotlin_pkg_path = kotlin_pkg
65            .as_ref()
66            .and_then(|p| p.path.as_ref())
67            .cloned()
68            .unwrap_or_else(|| "../../packages/kotlin".to_string());
69        let kotlin_version = kotlin_pkg
70            .as_ref()
71            .and_then(|p| p.version.as_ref())
72            .cloned()
73            .unwrap_or_else(|| "0.1.0".to_string());
74
75        // Generate build.gradle.kts.
76        files.push(GeneratedFile {
77            path: output_base.join("build.gradle.kts"),
78            content: render_build_gradle(&pkg_name, &kotlin_version, e2e_config.dep_mode),
79            generated_header: false,
80        });
81
82        // Generate test files per category.
83        let test_base = output_base
84            .join("src")
85            .join("test")
86            .join("kotlin")
87            .join("dev")
88            .join("kreuzberg")
89            .join("e2e");
90
91        // Resolve options_type from override.
92        let options_type = overrides.and_then(|o| o.options_type.clone());
93        let field_resolver = FieldResolver::new(
94            &e2e_config.fields,
95            &e2e_config.fields_optional,
96            &e2e_config.result_fields,
97            &e2e_config.fields_array,
98        );
99
100        for group in groups {
101            let active: Vec<&Fixture> = group
102                .fixtures
103                .iter()
104                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
105                .collect();
106
107            if active.is_empty() {
108                continue;
109            }
110
111            let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
112            let content = render_test_file(
113                &group.category,
114                &active,
115                &class_name,
116                &function_name,
117                result_var,
118                &e2e_config.call.args,
119                options_type.as_deref(),
120                &field_resolver,
121                result_is_simple,
122                &e2e_config.fields_enum,
123                e2e_config,
124            );
125            files.push(GeneratedFile {
126                path: test_base.join(class_file_name),
127                content,
128                generated_header: true,
129            });
130        }
131
132        Ok(files)
133    }
134
135    fn language_name(&self) -> &'static str {
136        "kotlin"
137    }
138}
139
140// ---------------------------------------------------------------------------
141// Rendering
142// ---------------------------------------------------------------------------
143
144fn render_build_gradle(pkg_name: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
145    let dep_block = match dep_mode {
146        crate::config::DependencyMode::Registry => {
147            format!(r#"    testImplementation("{pkg_name}:{pkg_version}")"#)
148        }
149        crate::config::DependencyMode::Local => {
150            // Local mode: reference local JAR or Maven build output.
151            format!(r#"    testImplementation(files("../../packages/kotlin/build/libs/{pkg_name}-{pkg_version}.jar"))"#)
152        }
153    };
154
155    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
156    let junit = maven::JUNIT;
157    let jackson = maven::JACKSON_E2E;
158    let jvm_target = toolchain::JVM_TARGET;
159    format!(
160        r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
161
162plugins {{
163    kotlin("jvm") version "{kotlin_plugin}"
164}}
165
166group = "dev.kreuzberg"
167version = "0.1.0"
168
169java {{
170    sourceCompatibility = JavaVersion.VERSION_{jvm_target}
171    targetCompatibility = JavaVersion.VERSION_{jvm_target}
172}}
173
174kotlin {{
175    compilerOptions {{
176        jvmTarget.set(JvmTarget.JVM_{jvm_target})
177    }}
178}}
179
180repositories {{
181    mavenCentral()
182}}
183
184dependencies {{
185{dep_block}
186    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
187    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
188    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
189    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
190}}
191
192tasks.test {{
193    useJUnitPlatform()
194    environment("java.library.path", "../../target/release")
195}}
196"#
197    )
198}
199
200#[allow(clippy::too_many_arguments)]
201fn render_test_file(
202    category: &str,
203    fixtures: &[&Fixture],
204    class_name: &str,
205    function_name: &str,
206    result_var: &str,
207    args: &[crate::config::ArgMapping],
208    options_type: Option<&str>,
209    field_resolver: &FieldResolver,
210    result_is_simple: bool,
211    enum_fields: &HashSet<String>,
212    e2e_config: &E2eConfig,
213) -> String {
214    let mut out = String::new();
215    out.push_str(&hash::header(CommentStyle::DoubleSlash));
216    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
217
218    // If the class_name is fully qualified (contains '.'), import it and use
219    // only the simple name for method calls. Otherwise use it as-is.
220    let (import_path, simple_class) = if class_name.contains('.') {
221        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
222        (class_name, simple)
223    } else {
224        ("", class_name)
225    };
226
227    let _ = writeln!(out, "package dev.kreuzberg.e2e");
228    let _ = writeln!(out);
229
230    // Check if any fixture uses a json_object arg with options_type (needs ObjectMapper).
231    let needs_object_mapper_for_options = options_type.is_some()
232        && fixtures.iter().any(|f| {
233            args.iter().any(|arg| {
234                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
235                arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
236            })
237        });
238    // Also need ObjectMapper when a handle arg has a non-null config.
239    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
240        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
241            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
242            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
243        })
244    });
245    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
246
247    let _ = writeln!(out, "import org.junit.jupiter.api.Test");
248    let _ = writeln!(out, "import kotlin.test.assertEquals");
249    let _ = writeln!(out, "import kotlin.test.assertTrue");
250    let _ = writeln!(out, "import kotlin.test.assertFalse");
251    let _ = writeln!(out, "import kotlin.test.assertFailsWith");
252    if !import_path.is_empty() {
253        let _ = writeln!(out, "import {import_path}");
254    }
255    if needs_object_mapper {
256        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
257        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
258    }
259    // Import the options type if tests use it (it's in the same package as the main class).
260    if let Some(opts_type) = options_type {
261        if needs_object_mapper {
262            // Derive the fully-qualified name from the main class import path.
263            let opts_package = if !import_path.is_empty() {
264                let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
265                format!("{pkg}.{opts_type}")
266            } else {
267                opts_type.to_string()
268            };
269            let _ = writeln!(out, "import {opts_package}");
270        }
271    }
272    // Import CrawlConfig when handle args need JSON deserialization.
273    if needs_object_mapper_for_handle && !import_path.is_empty() {
274        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
275        let _ = writeln!(out, "import {pkg}.CrawlConfig");
276    }
277    let _ = writeln!(out);
278
279    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
280    let _ = writeln!(out, "class {test_class_name} {{");
281
282    if needs_object_mapper {
283        let _ = writeln!(out);
284        let _ = writeln!(out, "    companion object {{");
285        let _ = writeln!(
286            out,
287            "        private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
288        );
289        let _ = writeln!(out, "    }}");
290    }
291
292    for fixture in fixtures {
293        render_test_method(
294            &mut out,
295            fixture,
296            simple_class,
297            function_name,
298            result_var,
299            args,
300            options_type,
301            field_resolver,
302            result_is_simple,
303            enum_fields,
304            e2e_config,
305        );
306        let _ = writeln!(out);
307    }
308
309    let _ = writeln!(out, "}}");
310    out
311}
312
313#[allow(clippy::too_many_arguments)]
314fn render_test_method(
315    out: &mut String,
316    fixture: &Fixture,
317    class_name: &str,
318    _function_name: &str,
319    _result_var: &str,
320    _args: &[crate::config::ArgMapping],
321    options_type: Option<&str>,
322    field_resolver: &FieldResolver,
323    result_is_simple: bool,
324    enum_fields: &HashSet<String>,
325    e2e_config: &E2eConfig,
326) {
327    // Resolve per-fixture call config (supports named calls via fixture.call field).
328    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
329    let lang = "kotlin";
330    let call_overrides = call_config.overrides.get(lang);
331    let effective_function_name = call_overrides
332        .and_then(|o| o.function.as_ref())
333        .cloned()
334        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
335    let effective_result_var = &call_config.result_var;
336    let effective_args = &call_config.args;
337    let function_name = effective_function_name.as_str();
338    let result_var = effective_result_var.as_str();
339    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
340
341    let method_name = fixture.id.to_upper_camel_case();
342    let description = &fixture.description;
343    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
344
345    // Check if this test needs ObjectMapper deserialization for json_object args.
346    let needs_deser = options_type.is_some()
347        && args
348            .iter()
349            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
350
351    let _ = writeln!(out, "    @Test");
352    let _ = writeln!(out, "    fun test{method_name}() {{");
353    let _ = writeln!(out, "        // {description}");
354
355    // Emit ObjectMapper deserialization bindings for json_object args.
356    if let (true, Some(opts_type)) = (needs_deser, options_type) {
357        for arg in args {
358            if arg.arg_type == "json_object" {
359                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
360                if let Some(val) = fixture.input.get(field) {
361                    if !val.is_null() {
362                        let normalized = super::normalize_json_keys_to_snake_case(val);
363                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
364                        let var_name = &arg.name;
365                        let _ = writeln!(
366                            out,
367                            "        val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
368                            escape_java(&json_str)
369                        );
370                    }
371                }
372            }
373        }
374    }
375
376    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
377
378    for line in &setup_lines {
379        let _ = writeln!(out, "        {line}");
380    }
381
382    if expects_error {
383        let _ = writeln!(
384            out,
385            "        assertFailsWith<Exception> {{ {class_name}.{function_name}({args_str}) }}"
386        );
387        let _ = writeln!(out, "    }}");
388        return;
389    }
390
391    let _ = writeln!(
392        out,
393        "        val {result_var} = {class_name}.{function_name}({args_str})"
394    );
395
396    for assertion in &fixture.assertions {
397        render_assertion(
398            out,
399            assertion,
400            result_var,
401            class_name,
402            field_resolver,
403            result_is_simple,
404            enum_fields,
405        );
406    }
407
408    let _ = writeln!(out, "    }}");
409}
410
411/// Build setup lines and the argument list for the function call.
412///
413/// Returns `(setup_lines, args_string)`.
414fn build_args_and_setup(
415    input: &serde_json::Value,
416    args: &[crate::config::ArgMapping],
417    class_name: &str,
418    options_type: Option<&str>,
419    fixture_id: &str,
420) -> (Vec<String>, String) {
421    if args.is_empty() {
422        return (Vec::new(), String::new());
423    }
424
425    let mut setup_lines: Vec<String> = Vec::new();
426    let mut parts: Vec<String> = Vec::new();
427
428    for arg in args {
429        if arg.arg_type == "mock_url" {
430            setup_lines.push(format!(
431                "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
432                arg.name,
433            ));
434            parts.push(arg.name.clone());
435            continue;
436        }
437
438        if arg.arg_type == "handle" {
439            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
440            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
441            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
442            if config_value.is_null()
443                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
444            {
445                setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
446            } else {
447                let json_str = serde_json::to_string(config_value).unwrap_or_default();
448                let name = &arg.name;
449                setup_lines.push(format!(
450                    "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
451                    escape_java(&json_str),
452                ));
453                setup_lines.push(format!(
454                    "val {} = {class_name}.{constructor_name}({name}Config)",
455                    arg.name,
456                    name = name,
457                ));
458            }
459            parts.push(arg.name.clone());
460            continue;
461        }
462
463        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
464        let val = input.get(field);
465        match val {
466            None | Some(serde_json::Value::Null) if arg.optional => {
467                continue;
468            }
469            None | Some(serde_json::Value::Null) => {
470                let default_val = match arg.arg_type.as_str() {
471                    "string" => "\"\"".to_string(),
472                    "int" | "integer" => "0".to_string(),
473                    "float" | "number" => "0.0".to_string(),
474                    "bool" | "boolean" => "false".to_string(),
475                    _ => "null".to_string(),
476                };
477                parts.push(default_val);
478            }
479            Some(v) => {
480                // For json_object args with options_type, use the pre-deserialized variable.
481                if arg.arg_type == "json_object" && options_type.is_some() {
482                    parts.push(arg.name.clone());
483                    continue;
484                }
485                // bytes args must be passed as ByteArray.
486                if arg.arg_type == "bytes" {
487                    let val = json_to_kotlin(v);
488                    parts.push(format!("{val}.toByteArray()"));
489                    continue;
490                }
491                parts.push(json_to_kotlin(v));
492            }
493        }
494    }
495
496    (setup_lines, parts.join(", "))
497}
498
499fn render_assertion(
500    out: &mut String,
501    assertion: &Assertion,
502    result_var: &str,
503    _class_name: &str,
504    field_resolver: &FieldResolver,
505    result_is_simple: bool,
506    enum_fields: &HashSet<String>,
507) {
508    // Skip assertions on fields that don't exist on the result type.
509    if let Some(f) = &assertion.field {
510        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
511            let _ = writeln!(out, "        // skipped: field '{{f}}' not available on result type");
512            return;
513        }
514    }
515
516    // Determine if this field is an enum type.
517    let field_is_enum = assertion
518        .field
519        .as_deref()
520        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
521
522    let field_expr = if result_is_simple {
523        result_var.to_string()
524    } else {
525        match &assertion.field {
526            Some(f) if !f.is_empty() => {
527                let accessor = field_resolver.accessor(f, "kotlin", result_var);
528                let resolved = field_resolver.resolve(f);
529                // In Kotlin, use .orEmpty() for Optional<String> fields.
530                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
531                    format!("{accessor}.orEmpty()")
532                } else {
533                    accessor
534                }
535            }
536            _ => result_var.to_string(),
537        }
538    };
539
540    // For enum fields, use .getValue() to get the string value.
541    let string_expr = if field_is_enum {
542        format!("{field_expr}.getValue()")
543    } else {
544        field_expr.clone()
545    };
546
547    match assertion.assertion_type.as_str() {
548        "equals" => {
549            if let Some(expected) = &assertion.value {
550                let kotlin_val = json_to_kotlin(expected);
551                if expected.is_string() {
552                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {string_expr}.trim())");
553                } else {
554                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {field_expr})");
555                }
556            }
557        }
558        "contains" => {
559            if let Some(expected) = &assertion.value {
560                let kotlin_val = json_to_kotlin(expected);
561                let _ = writeln!(
562                    out,
563                    "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
564                );
565            }
566        }
567        "contains_all" => {
568            if let Some(values) = &assertion.values {
569                for val in values {
570                    let kotlin_val = json_to_kotlin(val);
571                    let _ = writeln!(
572                        out,
573                        "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
574                    );
575                }
576            }
577        }
578        "not_contains" => {
579            if let Some(expected) = &assertion.value {
580                let kotlin_val = json_to_kotlin(expected);
581                let _ = writeln!(
582                    out,
583                    "        assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
584                );
585            }
586        }
587        "not_empty" => {
588            let _ = writeln!(
589                out,
590                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\")"
591            );
592        }
593        "is_empty" => {
594            let _ = writeln!(
595                out,
596                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\")"
597            );
598        }
599        "contains_any" => {
600            if let Some(values) = &assertion.values {
601                let checks: Vec<String> = values
602                    .iter()
603                    .map(|v| {
604                        let kotlin_val = json_to_kotlin(v);
605                        format!("{string_expr}.contains({kotlin_val})")
606                    })
607                    .collect();
608                let joined = checks.join(" || ");
609                let _ = writeln!(
610                    out,
611                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\")"
612                );
613            }
614        }
615        "greater_than" => {
616            if let Some(val) = &assertion.value {
617                let kotlin_val = json_to_kotlin(val);
618                let _ = writeln!(
619                    out,
620                    "        assertTrue({field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
621                );
622            }
623        }
624        "less_than" => {
625            if let Some(val) = &assertion.value {
626                let kotlin_val = json_to_kotlin(val);
627                let _ = writeln!(
628                    out,
629                    "        assertTrue({field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
630                );
631            }
632        }
633        "greater_than_or_equal" => {
634            if let Some(val) = &assertion.value {
635                let kotlin_val = json_to_kotlin(val);
636                let _ = writeln!(
637                    out,
638                    "        assertTrue({field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
639                );
640            }
641        }
642        "less_than_or_equal" => {
643            if let Some(val) = &assertion.value {
644                let kotlin_val = json_to_kotlin(val);
645                let _ = writeln!(
646                    out,
647                    "        assertTrue({field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
648                );
649            }
650        }
651        "starts_with" => {
652            if let Some(expected) = &assertion.value {
653                let kotlin_val = json_to_kotlin(expected);
654                let _ = writeln!(
655                    out,
656                    "        assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
657                );
658            }
659        }
660        "ends_with" => {
661            if let Some(expected) = &assertion.value {
662                let kotlin_val = json_to_kotlin(expected);
663                let _ = writeln!(
664                    out,
665                    "        assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
666                );
667            }
668        }
669        "min_length" => {
670            if let Some(val) = &assertion.value {
671                if let Some(n) = val.as_u64() {
672                    let _ = writeln!(
673                        out,
674                        "        assertTrue({field_expr}.length >= {n}, \"expected length >= {n}\")"
675                    );
676                }
677            }
678        }
679        "max_length" => {
680            if let Some(val) = &assertion.value {
681                if let Some(n) = val.as_u64() {
682                    let _ = writeln!(
683                        out,
684                        "        assertTrue({field_expr}.length <= {n}, \"expected length <= {n}\")"
685                    );
686                }
687            }
688        }
689        "count_min" => {
690            if let Some(val) = &assertion.value {
691                if let Some(n) = val.as_u64() {
692                    let _ = writeln!(
693                        out,
694                        "        assertTrue({field_expr}.size >= {n}, \"expected at least {n} elements\")"
695                    );
696                }
697            }
698        }
699        "count_equals" => {
700            if let Some(val) = &assertion.value {
701                if let Some(n) = val.as_u64() {
702                    let _ = writeln!(
703                        out,
704                        "        assertEquals({n}, {field_expr}.size, \"expected exactly {n} elements\")"
705                    );
706                }
707            }
708        }
709        "is_true" => {
710            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\")");
711        }
712        "is_false" => {
713            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\")");
714        }
715        "matches_regex" => {
716            if let Some(expected) = &assertion.value {
717                let kotlin_val = json_to_kotlin(expected);
718                let _ = writeln!(
719                    out,
720                    "        assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
721                );
722            }
723        }
724        "not_error" => {
725            // Already handled by the call succeeding without exception.
726        }
727        "error" => {
728            // Handled at the test method level.
729        }
730        "method_result" => {
731            // Placeholder: Kotlin support for method_result would need tree-sitter integration.
732            let _ = writeln!(
733                out,
734                "        // method_result assertions not yet implemented for Kotlin"
735            );
736        }
737        other => {
738            panic!("Kotlin e2e generator: unsupported assertion type: {other}");
739        }
740    }
741}
742
743/// Convert a `serde_json::Value` to a Kotlin literal string.
744fn json_to_kotlin(value: &serde_json::Value) -> String {
745    match value {
746        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
747        serde_json::Value::Bool(b) => b.to_string(),
748        serde_json::Value::Number(n) => {
749            if n.is_f64() {
750                format!("{}d", n)
751            } else {
752                n.to_string()
753            }
754        }
755        serde_json::Value::Null => "null".to_string(),
756        serde_json::Value::Array(arr) => {
757            let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
758            format!("listOf({})", items.join(", "))
759        }
760        serde_json::Value::Object(_) => {
761            let json_str = serde_json::to_string(value).unwrap_or_default();
762            format!("\"{}\"", escape_java(&json_str))
763        }
764    }
765}