1use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::field_access::FieldResolver;
11use crate::fixture::{Fixture, FixtureGroup};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::template_versions::{maven, toolchain};
15use anyhow::Result;
16use heck::ToUpperCamelCase;
17use std::collections::HashSet;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::kotlin;
22
23pub struct KotlinAndroidE2eCodegen;
27
28impl E2eCodegen for KotlinAndroidE2eCodegen {
29 fn generate(
30 &self,
31 groups: &[FixtureGroup],
32 e2e_config: &E2eConfig,
33 config: &ResolvedCrateConfig,
34 type_defs: &[alef_core::ir::TypeDef],
35 _enums: &[alef_core::ir::EnumDef],
36 ) -> Result<Vec<GeneratedFile>> {
37 let lang = self.language_name();
38 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
39
40 let mut files = Vec::new();
41
42 let call = &e2e_config.call;
44 let overrides = call.overrides.get(lang);
45 let _module_path = overrides
46 .and_then(|o| o.module.as_ref())
47 .cloned()
48 .unwrap_or_else(|| call.module.clone());
49 let function_name = overrides
50 .and_then(|o| o.function.as_ref())
51 .cloned()
52 .unwrap_or_else(|| call.function.clone());
53 let class_name = overrides
54 .and_then(|o| o.class.as_ref())
55 .cloned()
56 .unwrap_or_else(|| config.name.to_upper_camel_case());
57 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
58 let result_var = &call.result_var;
59
60 let kotlin_android_pkg = e2e_config.resolve_package("kotlin_android");
62 let pkg_name = kotlin_android_pkg
63 .as_ref()
64 .and_then(|p| p.name.as_ref())
65 .cloned()
66 .unwrap_or_else(|| config.name.clone());
67
68 let _kotlin_android_pkg_path = kotlin_android_pkg
70 .as_ref()
71 .and_then(|p| p.path.as_deref())
72 .unwrap_or("../../packages/kotlin-android");
73 let kotlin_android_version = kotlin_android_pkg
74 .as_ref()
75 .and_then(|p| p.version.as_ref())
76 .cloned()
77 .or_else(|| config.resolved_version())
78 .unwrap_or_else(|| "0.1.0".to_string());
79 let kotlin_pkg_id = kotlin_android_pkg
92 .as_ref()
93 .and_then(|p| p.module.clone())
94 .or_else(|| config.kotlin_android.as_ref().and_then(|c| c.package.clone()))
95 .unwrap_or_else(|| config.kotlin_package());
96
97 let needs_mock_server = groups
103 .iter()
104 .flat_map(|g| g.fixtures.iter())
105 .any(|f| f.needs_mock_server());
106
107 files.push(GeneratedFile {
109 path: output_base.join("build.gradle.kts"),
110 content: render_build_gradle_kotlin_android(
111 &pkg_name,
112 &kotlin_pkg_id,
113 &kotlin_android_version,
114 e2e_config.dep_mode,
115 needs_mock_server,
116 ),
117 generated_header: false,
118 });
119
120 files.push(GeneratedFile {
126 path: output_base.join("settings.gradle.kts"),
127 content: render_settings_gradle_kotlin_android(&pkg_name),
128 generated_header: false,
129 });
130
131 let mut test_base = output_base.join("src").join("test").join("kotlin");
135 for segment in kotlin_pkg_id.split('.') {
136 test_base = test_base.join(segment);
137 }
138 let test_base = test_base.join("e2e");
139
140 if needs_mock_server {
141 files.push(GeneratedFile {
142 path: test_base.join("MockServerListener.kt"),
143 content: kotlin::render_mock_server_listener_kt(&kotlin_pkg_id),
144 generated_header: true,
145 });
146 files.push(GeneratedFile {
147 path: output_base
148 .join("src")
149 .join("test")
150 .join("resources")
151 .join("META-INF")
152 .join("services")
153 .join("org.junit.platform.launcher.LauncherSessionListener"),
154 content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
155 generated_header: false,
156 });
157 }
158
159 let options_type = overrides.and_then(|o| o.options_type.clone());
161 let field_resolver = FieldResolver::new(
162 &e2e_config.fields,
163 &e2e_config.fields_optional,
164 &e2e_config.result_fields,
165 &e2e_config.fields_array,
166 &HashSet::new(),
167 );
168
169 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
175 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
176 .iter()
177 .filter_map(|td| {
178 let enum_field_names: HashSet<String> = td
179 .fields
180 .iter()
181 .filter(|field| is_enum_typed(&field.ty, &struct_names))
182 .map(|field| field.name.clone())
183 .collect();
184 if enum_field_names.is_empty() {
185 None
186 } else {
187 Some((td.name.clone(), enum_field_names))
188 }
189 })
190 .collect();
191
192 for group in groups {
193 let active: Vec<&Fixture> = group
194 .fixtures
195 .iter()
196 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
197 .collect();
198
199 if active.is_empty() {
200 continue;
201 }
202
203 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
204 let content = kotlin::render_test_file_android(
205 &group.category,
206 &active,
207 &class_name,
208 &function_name,
209 &kotlin_pkg_id,
210 result_var,
211 &e2e_config.call.args,
212 options_type.as_deref(),
213 &field_resolver,
214 result_is_simple,
215 &e2e_config.fields_enum,
216 e2e_config,
217 &type_enum_fields,
218 );
219 files.push(GeneratedFile {
220 path: test_base.join(&class_file_name),
221 content,
222 generated_header: true,
223 });
224
225 let mut android_test_base = output_base.join("src").join("androidTest").join("kotlin");
228 for segment in kotlin_pkg_id.split('.') {
229 android_test_base = android_test_base.join(segment);
230 }
231 let android_test_base = android_test_base.join("e2e");
232 files.push(GeneratedFile {
233 path: android_test_base.join(class_file_name),
234 content: render_android_instrumented_test(
235 &group.category,
236 &active,
237 &class_name,
238 &function_name,
239 &kotlin_pkg_id,
240 result_var,
241 &pkg_name,
242 ),
243 generated_header: true,
244 });
245 }
246
247 Ok(files)
248 }
249
250 fn language_name(&self) -> &'static str {
251 "kotlin_android"
252 }
253}
254
255fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
263 use alef_core::ir::TypeRef;
264 match ty {
265 TypeRef::Named(name) => !struct_names.contains(name.as_str()),
266 TypeRef::Optional(inner) => {
267 matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
268 }
269 _ => false,
270 }
271}
272
273fn render_build_gradle_kotlin_android(
284 _pkg_name: &str,
285 kotlin_pkg_id: &str,
286 _pkg_version: &str,
287 _dep_mode: crate::config::DependencyMode,
288 needs_mock_server: bool,
289) -> String {
290 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
291 let android_gradle_plugin = maven::ANDROID_GRADLE_PLUGIN;
292 let junit = maven::JUNIT;
293 let jackson = maven::JACKSON_E2E;
294 let jvm_target = if junit.starts_with("6.") {
299 "17"
300 } else {
301 toolchain::ANDROID_JVM_TARGET
302 };
303 let jna = maven::JNA;
304 let jspecify = maven::JSPECIFY;
305 let coroutines = maven::KOTLINX_COROUTINES_CORE;
306 let launcher_dep = if needs_mock_server {
307 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
308 } else {
309 String::new()
310 };
311
312 format!(
313 r#"import com.android.build.api.dsl.ManagedVirtualDevice
314import org.jetbrains.kotlin.gradle.dsl.JvmTarget
315
316plugins {{
317 id("com.android.library") version "{android_gradle_plugin}"
318 kotlin("android") version "{kotlin_plugin}"
319}}
320
321group = "{kotlin_pkg_id}"
322version = "0.1.0"
323
324android {{
325 namespace = "{kotlin_pkg_id}.e2e"
326 compileSdk = 35
327
328 defaultConfig {{
329 minSdk = 21
330 }}
331
332 compileOptions {{
333 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
334 targetCompatibility = JavaVersion.VERSION_{jvm_target}
335 }}
336
337 sourceSets {{
338 getByName("test") {{
339 // Include the AAR-bundled Java facade as test sources
340 java.srcDir("../../packages/kotlin-android/src/main/java")
341 // Include the AAR-bundled Kotlin wrapper as test sources
342 kotlin.srcDir("../../packages/kotlin-android/src/main/kotlin")
343 }}
344 }}
345
346 testOptions {{
347 // Gradle Managed Virtual Devices for on-device instrumented tests.
348 // Run: ./gradlew pixel6api34DebugAndroidTest
349 managedDevices {{
350 devices {{
351 create<ManagedVirtualDevice>("pixel6api34") {{
352 device = "Pixel 6"
353 apiLevel = 34
354 systemImageSource = "aosp"
355 }}
356 }}
357 }}
358 }}
359}}
360
361kotlin {{
362 compilerOptions {{
363 jvmTarget = JvmTarget.JVM_{jvm_target}
364 }}
365}}
366
367// Repositories declared in settings.gradle.kts via
368// dependencyResolutionManagement (FAIL_ON_PROJECT_REPOS). Re-declaring them
369// here triggers Gradle "repository was added by build file" errors.
370
371dependencies {{
372 // JNA for loading the native library from java.library.path
373 testImplementation("net.java.dev.jna:jna:{jna}")
374
375 // Jackson for JSON assertion helpers
376 testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
377 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
378 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
379
380 // jackson-module-kotlin registers constructors/properties for Kotlin data
381 // classes, which have no default constructor and cannot be deserialized by
382 // plain Jackson without this module.
383 testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:{jackson}")
384
385 // jspecify for null-safety annotations on wrapped types
386 testImplementation("org.jspecify:jspecify:{jspecify}")
387
388 // Kotlin coroutines for async test helpers
389 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")
390
391 // JUnit 5 API and engine
392 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
393 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
394{launcher_dep}
395
396 // Kotlin stdlib test helpers
397 testImplementation(kotlin("test"))
398}}
399
400tasks.withType<Test> {{
401 useJUnitPlatform()
402
403 // Resolve the native library location (e.g., ../../target/release)
404 val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
405 systemProperty("java.library.path", libPath)
406 systemProperty("jna.library.path", libPath)
407
408 // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/
409 workingDir = file("${{rootDir}}/../../test_documents")
410}}
411"#
412 )
413}
414
415fn render_settings_gradle_kotlin_android(pkg_name: &str) -> String {
421 format!(
422 r#"// Generated by alef. Do not edit by hand.
423
424pluginManagement {{
425 repositories {{
426 google()
427 mavenCentral()
428 gradlePluginPortal()
429 }}
430}}
431
432dependencyResolutionManagement {{
433 repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
434 repositories {{
435 google()
436 mavenCentral()
437 }}
438}}
439
440rootProject.name = "{pkg_name}-e2e"
441"#
442 )
443}
444
445fn render_android_instrumented_test(
451 category: &str,
452 fixtures: &[&crate::fixture::Fixture],
453 class_name: &str,
454 function_name: &str,
455 kotlin_pkg_id: &str,
456 result_var: &str,
457 lib_name: &str,
458) -> String {
459 let test_class = format!("{}Test", category.to_upper_camel_case());
460 let lib_snake = lib_name.replace('-', "_");
461 let mut out = String::new();
462 out.push_str(&format!("package {kotlin_pkg_id}.e2e\n\n"));
463 out.push_str("import androidx.test.ext.junit.runners.AndroidJUnit4\n");
464 out.push_str("import org.junit.BeforeClass\n");
465 out.push_str("import org.junit.Test\n");
466 out.push_str("import org.junit.runner.RunWith\n\n");
467 out.push_str("@RunWith(AndroidJUnit4::class)\n");
468 out.push_str(&format!("class {test_class} {{\n\n"));
469 out.push_str(" companion object {\n");
470 out.push_str(" @BeforeClass\n");
471 out.push_str(" @JvmStatic\n");
472 out.push_str(" fun loadNativeLibrary() {\n");
473 out.push_str(&format!(" System.loadLibrary(\"{lib_snake}_jni\")\n"));
474 out.push_str(" }\n");
475 out.push_str(" }\n\n");
476 for fixture in fixtures {
477 let test_name = fixture.id.replace(['-', '.', ' '], "_");
478 out.push_str(" @Test\n");
479 out.push_str(&format!(" fun test_{test_name}() {{\n"));
480 out.push_str(&format!(" val client = {class_name}()\n"));
481 out.push_str(&format!(
482 " val {result_var} = client.{function_name}(/* fixture: {} */)\n",
483 fixture.id
484 ));
485 out.push_str(&format!(" // TODO: assert {result_var} is not an error\n"));
486 out.push_str(" }\n\n");
487 }
488 out.push_str("}\n");
489 out
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
502 fn build_gradle_kotlin_android_includes_jackson_module_kotlin() {
503 let output = render_build_gradle_kotlin_android(
504 "liter-llm",
505 "dev.kreuzberg.literllm.android",
506 "1.0.0",
507 crate::config::DependencyMode::Local,
508 false,
509 );
510 assert!(
511 output.contains("jackson-module-kotlin"),
512 "build.gradle.kts must depend on jackson-module-kotlin, got:\n{output}"
513 );
514 }
515
516 #[test]
521 fn settings_gradle_kotlin_android_declares_plugin_repositories() {
522 let output = render_settings_gradle_kotlin_android("liter-llm");
523 assert!(
524 output.contains("pluginManagement"),
525 "settings.gradle.kts must declare pluginManagement block, got:\n{output}"
526 );
527 assert!(
528 output.contains("google()"),
529 "pluginManagement repositories must include google(), got:\n{output}"
530 );
531 assert!(
532 output.contains("gradlePluginPortal()"),
533 "pluginManagement repositories must include gradlePluginPortal(), got:\n{output}"
534 );
535 assert!(
536 output.contains("rootProject.name = \"liter-llm-e2e\""),
537 "rootProject.name must be derived from pkg_name, got:\n{output}"
538 );
539 }
540}