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