use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::template_versions::{maven, toolchain};
use crate::e2e::config::E2eConfig;
use crate::e2e::escape::sanitize_filename;
use crate::e2e::fixture::{Fixture, FixtureGroup};
use anyhow::Result;
use heck::ToUpperCamelCase;
use std::collections::HashSet;
use std::path::PathBuf;
use super::E2eCodegen;
use super::kotlin;
pub struct KotlinAndroidE2eCodegen;
impl E2eCodegen for KotlinAndroidE2eCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
_enums: &[crate::core::ir::EnumDef],
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let mut files = Vec::new();
let call = &e2e_config.call;
let overrides = call.overrides.get(lang);
let _module_path = overrides
.and_then(|o| o.module.as_ref())
.cloned()
.unwrap_or_else(|| call.module.clone());
let function_name = overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call.function.clone());
let class_name = overrides
.and_then(|o| o.class.as_ref())
.cloned()
.unwrap_or_else(|| config.name.to_upper_camel_case());
let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
let result_var = &call.result_var;
let kotlin_android_pkg = e2e_config.resolve_package("kotlin_android");
let pkg_name = kotlin_android_pkg
.as_ref()
.and_then(|p| p.name.as_ref())
.cloned()
.unwrap_or_else(|| config.name.clone());
let _kotlin_android_pkg_path = kotlin_android_pkg
.as_ref()
.and_then(|p| p.path.as_deref())
.unwrap_or("../../packages/kotlin-android");
let kotlin_android_version = kotlin_android_pkg
.as_ref()
.and_then(|p| p.version.as_ref())
.cloned()
.or_else(|| config.resolved_version())
.unwrap_or_else(|| "0.1.0".to_string());
let kotlin_pkg_id = kotlin_android_pkg
.as_ref()
.and_then(|p| p.module.clone())
.or_else(|| config.kotlin_android.as_ref().and_then(|c| c.package.clone()))
.unwrap_or_else(|| config.kotlin_package());
let needs_mock_server = groups
.iter()
.flat_map(|g| g.fixtures.iter())
.any(|f| f.needs_mock_server());
files.push(GeneratedFile {
path: output_base.join("build.gradle.kts"),
content: render_build_gradle_kotlin_android(
&pkg_name,
&kotlin_pkg_id,
&kotlin_android_version,
e2e_config.dep_mode,
needs_mock_server,
),
generated_header: false,
});
files.push(GeneratedFile {
path: output_base.join("settings.gradle.kts"),
content: render_settings_gradle_kotlin_android(&pkg_name),
generated_header: false,
});
let mut test_base = output_base.join("src").join("test").join("kotlin");
for segment in kotlin_pkg_id.split('.') {
test_base = test_base.join(segment);
}
let test_base = test_base.join("e2e");
if needs_mock_server {
files.push(GeneratedFile {
path: test_base.join("MockServerListener.kt"),
content: kotlin::render_mock_server_listener_kt(&kotlin_pkg_id),
generated_header: true,
});
files.push(GeneratedFile {
path: output_base
.join("src")
.join("test")
.join("resources")
.join("META-INF")
.join("services")
.join("org.junit.platform.launcher.LauncherSessionListener"),
content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
generated_header: false,
});
}
let options_type = overrides.and_then(|o| o.options_type.clone());
let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
.iter()
.filter_map(|td| {
let enum_field_names: HashSet<String> = td
.fields
.iter()
.filter(|field| is_enum_typed(&field.ty, &struct_names))
.map(|field| field.name.clone())
.collect();
if enum_field_names.is_empty() {
None
} else {
Some((td.name.clone(), enum_field_names))
}
})
.collect();
for group in groups {
let active: Vec<&Fixture> = group
.fixtures
.iter()
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
.filter(|f| f.visitor.is_none())
.collect();
if active.is_empty() {
continue;
}
let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
let content = kotlin::render_test_file_android(
&group.category,
&active,
&class_name,
&function_name,
&kotlin_pkg_id,
result_var,
&e2e_config.call.args,
options_type.as_deref(),
result_is_simple,
e2e_config,
&type_enum_fields,
config,
type_defs,
);
files.push(GeneratedFile {
path: test_base.join(&class_file_name),
content,
generated_header: true,
});
let mut android_test_base = output_base.join("src").join("androidTest").join("kotlin");
for segment in kotlin_pkg_id.split('.') {
android_test_base = android_test_base.join(segment);
}
let android_test_base = android_test_base.join("e2e");
files.push(GeneratedFile {
path: android_test_base.join(class_file_name),
content: render_android_instrumented_test(
&group.category,
&active,
&class_name,
&function_name,
&kotlin_pkg_id,
result_var,
&pkg_name,
),
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"kotlin_android"
}
}
fn is_enum_typed(ty: &crate::core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
use crate::core::ir::TypeRef;
match ty {
TypeRef::Named(name) => !struct_names.contains(name.as_str()),
TypeRef::Optional(inner) => {
matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
}
_ => false,
}
}
fn render_build_gradle_kotlin_android(
_pkg_name: &str,
kotlin_pkg_id: &str,
_pkg_version: &str,
_dep_mode: crate::e2e::config::DependencyMode,
needs_mock_server: bool,
) -> String {
let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
let android_gradle_plugin = maven::ANDROID_GRADLE_PLUGIN;
let junit = maven::JUNIT;
let jackson = maven::JACKSON_E2E;
let jvm_target = if junit.starts_with("6.") {
"17"
} else {
toolchain::ANDROID_JVM_TARGET
};
let jna = maven::JNA;
let jspecify = maven::JSPECIFY;
let coroutines = maven::KOTLINX_COROUTINES_CORE;
let launcher_dep = if needs_mock_server {
format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
} else {
String::new()
};
format!(
r#"import com.android.build.api.dsl.ManagedVirtualDevice
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {{
id("com.android.library") version "{android_gradle_plugin}"
kotlin("android") version "{kotlin_plugin}"
}}
group = "{kotlin_pkg_id}"
version = "0.1.0"
android {{
namespace = "{kotlin_pkg_id}.e2e"
compileSdk = 35
defaultConfig {{
minSdk = 21
}}
compileOptions {{
sourceCompatibility = JavaVersion.VERSION_{jvm_target}
targetCompatibility = JavaVersion.VERSION_{jvm_target}
}}
sourceSets {{
getByName("test") {{
// Include the AAR-bundled Java facade as test sources
java.srcDir("../../packages/kotlin-android/src/main/java")
// Include the AAR-bundled Kotlin wrapper as test sources
kotlin.srcDir("../../packages/kotlin-android/src/main/kotlin")
}}
}}
testOptions {{
// Gradle Managed Virtual Devices for on-device instrumented tests.
// Run: ./gradlew pixel6api34DebugAndroidTest
managedDevices {{
devices {{
create<ManagedVirtualDevice>("pixel6api34") {{
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp"
}}
}}
}}
}}
}}
kotlin {{
compilerOptions {{
jvmTarget = JvmTarget.JVM_{jvm_target}
}}
}}
// Repositories declared in settings.gradle.kts via
// dependencyResolutionManagement (FAIL_ON_PROJECT_REPOS). Re-declaring them
// here triggers Gradle "repository was added by build file" errors.
dependencies {{
// JNA for loading the native library from java.library.path
testImplementation("net.java.dev.jna:jna:{jna}")
// Jackson for JSON assertion helpers
testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
// jackson-module-kotlin registers constructors/properties for Kotlin data
// classes, which have no default constructor and cannot be deserialized by
// plain Jackson without this module.
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:{jackson}")
// jspecify for null-safety annotations on wrapped types
testImplementation("org.jspecify:jspecify:{jspecify}")
// Kotlin coroutines for async test helpers
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")
// JUnit 5 API and engine
testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
{launcher_dep}
// Kotlin stdlib test helpers
testImplementation(kotlin("test"))
}}
tasks.withType<Test> {{
useJUnitPlatform()
// Resolve the native library location (e.g., ../../target/release)
val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
systemProperty("java.library.path", libPath)
systemProperty("jna.library.path", libPath)
// Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/
workingDir = file("${{rootDir}}/../../test_documents")
}}
"#
)
}
fn render_settings_gradle_kotlin_android(pkg_name: &str) -> String {
let project_name = sanitize_gradle_project_name(pkg_name);
format!(
r#"// Generated by alef. Do not edit by hand.
pluginManagement {{
repositories {{
google()
mavenCentral()
gradlePluginPortal()
}}
}}
dependencyResolutionManagement {{
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {{
google()
mavenCentral()
}}
}}
rootProject.name = "{project_name}-e2e"
"#
)
}
fn sanitize_gradle_project_name(pkg_name: &str) -> String {
let artifact = pkg_name.rsplit(':').next().unwrap_or(pkg_name);
artifact
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '<' | '>' | '"' | '?' | '*' | '|' => '-',
other => other,
})
.collect()
}
fn render_android_instrumented_test(
category: &str,
fixtures: &[&crate::e2e::fixture::Fixture],
class_name: &str,
function_name: &str,
kotlin_pkg_id: &str,
result_var: &str,
lib_name: &str,
) -> String {
let test_class = format!("{}Test", category.to_upper_camel_case());
let lib_snake = lib_name.replace('-', "_");
let mut out = String::new();
out.push_str(&format!("package {kotlin_pkg_id}.e2e\n\n"));
out.push_str("import androidx.test.ext.junit.runners.AndroidJUnit4\n");
out.push_str("import org.junit.BeforeClass\n");
out.push_str("import org.junit.Test\n");
out.push_str("import org.junit.runner.RunWith\n\n");
out.push_str("@RunWith(AndroidJUnit4::class)\n");
out.push_str(&format!("class {test_class} {{\n\n"));
out.push_str(" companion object {\n");
out.push_str(" @BeforeClass\n");
out.push_str(" @JvmStatic\n");
out.push_str(" fun loadNativeLibrary() {\n");
out.push_str(&format!(" System.loadLibrary(\"{lib_snake}_jni\")\n"));
out.push_str(" }\n");
out.push_str(" }\n\n");
for fixture in fixtures {
let test_name = fixture.id.replace(['-', '.', ' '], "_");
out.push_str(" @Test\n");
out.push_str(&format!(" fun test_{test_name}() {{\n"));
out.push_str(&format!(" val client = {class_name}()\n"));
out.push_str(&format!(
" val {result_var} = client.{function_name}(/* fixture: {} */)\n",
fixture.id
));
out.push_str(&format!(" // TODO: assert {result_var} is not an error\n"));
out.push_str(" }\n\n");
}
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_gradle_kotlin_android_includes_jackson_module_kotlin() {
let output = render_build_gradle_kotlin_android(
"liter-llm",
"dev.kreuzberg.literllm.android",
"1.0.0",
crate::e2e::config::DependencyMode::Local,
false,
);
assert!(
output.contains("jackson-module-kotlin"),
"build.gradle.kts must depend on jackson-module-kotlin, got:\n{output}"
);
}
#[test]
fn settings_gradle_kotlin_android_declares_plugin_repositories() {
let output = render_settings_gradle_kotlin_android("liter-llm");
assert!(
output.contains("pluginManagement"),
"settings.gradle.kts must declare pluginManagement block, got:\n{output}"
);
assert!(
output.contains("google()"),
"pluginManagement repositories must include google(), got:\n{output}"
);
assert!(
output.contains("gradlePluginPortal()"),
"pluginManagement repositories must include gradlePluginPortal(), got:\n{output}"
);
assert!(
output.contains("rootProject.name = \"liter-llm-e2e\""),
"rootProject.name must be derived from pkg_name, got:\n{output}"
);
}
#[test]
fn settings_gradle_kotlin_android_strips_maven_group_from_project_name() {
let output = render_settings_gradle_kotlin_android("dev.kreuzberg:html-to-markdown-android");
assert!(
output.contains("rootProject.name = \"html-to-markdown-android-e2e\""),
"rootProject.name must strip Maven group prefix, got:\n{output}"
);
let project_name_line = output
.lines()
.find(|line| line.starts_with("rootProject.name"))
.expect("rootProject.name line must be emitted");
assert!(
!project_name_line.contains(':'),
"rootProject.name line must not contain Gradle-reserved ':', got:\n{project_name_line}"
);
}
}
pub fn emit_test_backend(
trait_bridge: &crate::core::config::TraitBridgeConfig,
methods: &[&crate::core::ir::MethodDef],
fixture: &crate::e2e::fixture::Fixture,
) -> super::TestBackendEmission {
use crate::backends::kotlin::type_map::KotlinMapper;
use crate::codegen::defaults::language_defaults;
use crate::codegen::type_mapper::TypeMapper as _;
use heck::{ToLowerCamelCase, ToUpperCamelCase};
use std::fmt::Write as _;
let pascal_id = fixture.id.to_upper_camel_case();
let class_name = format!("TestStub{pascal_id}");
let interface_name = format!("I{}", trait_bridge.trait_name);
let bridge_object = crate::backends::kotlin_android::naming::bridge_object_name(&trait_bridge.trait_name);
let plugin_name = fixture
.input
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&fixture.id)
.to_string();
let defaults = language_defaults("kotlin_android");
let mapper = KotlinMapper;
let mut setup = String::new();
let _ = writeln!(setup, "class {class_name} : {interface_name} {{");
if trait_bridge.super_trait.is_some() {
let _ = writeln!(setup, " override fun name(): String = \"{plugin_name}\"");
}
for method in methods {
if method.has_default_impl {
continue;
}
let method_name = method.name.to_lower_camel_case();
let params: Vec<String> = method
.params
.iter()
.map(|p| format!("{}: {}", p.name.to_lower_camel_case(), mapper.map_type(&p.ty)))
.collect();
let params_str = params.join(", ");
let return_type = mapper.map_type(&method.return_type);
let default_val = defaults.emit_default(&method.return_type);
if method.is_async {
let _ = writeln!(
setup,
" override suspend fun {method_name}({params_str}): {return_type} = {default_val}"
);
} else {
let _ = writeln!(
setup,
" override fun {method_name}({params_str}): {return_type} = {default_val}"
);
}
}
let _ = writeln!(setup, "}}");
let arg_expr = format!("{class_name}()");
let _ = writeln!(setup, "// register via: {bridge_object}.register({class_name}())");
super::TestBackendEmission {
setup_block: setup,
arg_expr,
type_imports: Vec::new(),
}
}
#[cfg(test)]
mod test_backend_tests {
use super::emit_test_backend;
use crate::core::config::TraitBridgeConfig;
use crate::core::ir::{MethodDef, PrimitiveType, TypeRef};
use crate::e2e::fixture::Fixture;
fn make_trait_bridge(trait_name: &str) -> TraitBridgeConfig {
TraitBridgeConfig {
trait_name: trait_name.to_string(),
super_trait: Some("Plugin".to_string()),
register_fn: Some(format!("register_{}", trait_name.to_lowercase())),
..Default::default()
}
}
fn make_method(name: &str, required: bool) -> MethodDef {
MethodDef {
name: name.to_string(),
params: vec![],
return_type: TypeRef::Primitive(PrimitiveType::Bool),
is_async: false,
is_static: false,
error_type: None,
doc: String::new(),
receiver: Some(crate::core::ir::ReceiverKind::Ref),
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: !required,
binding_excluded: false,
binding_exclusion_reason: None,
}
}
fn make_fixture(id: &str) -> Fixture {
Fixture {
id: id.to_string(),
category: None,
description: "test".to_string(),
tags: vec![],
skip: None,
env: None,
call: None,
input: serde_json::Value::Null,
mock_response: None,
source: String::new(),
http: None,
assertions: vec![],
visitor: None,
args: vec![],
}
}
#[test]
fn kotlin_android_stub_contains_no_kreuzberg_domain_names() {
let bridge = make_trait_bridge("TestTrait");
let required_method = make_method("process_item", true);
let methods = [&required_method];
let fixture = make_fixture("my_test_fixture");
let emission = emit_test_backend(&bridge, &methods, &fixture);
let output = format!("{}\n{}", emission.setup_block, emission.arg_expr);
assert!(
!output.contains("Kreuzberg"),
"must not contain literal 'Kreuzberg', got:\n{output}"
);
assert!(
!output.contains("kreuzberg::"),
"must not contain 'kreuzberg::', got:\n{output}"
);
assert!(
!output.contains("KreuzbergBridge"),
"must not contain 'KreuzbergBridge', got:\n{output}"
);
assert!(
output.contains("TestStubMyTestFixture"),
"class name must be derived from fixture id, got:\n{output}"
);
assert!(
output.contains("ITestTrait"),
"class must implement interface derived from trait name, got:\n{output}"
);
assert!(
output.contains("TestTraitBridge"),
"setup block must reference the bridge object derived from trait name, got:\n{output}"
);
assert!(
output.contains("processItem"),
"required method must be emitted in camelCase, got:\n{output}"
);
}
fn make_param(name: &str, ty: crate::core::ir::TypeRef) -> crate::core::ir::ParamDef {
crate::core::ir::ParamDef {
name: name.to_string(),
ty,
optional: false,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
map_is_ahash: false,
map_key_is_cow: false,
}
}
fn make_method_with_params(name: &str, required: bool) -> MethodDef {
MethodDef {
name: name.to_string(),
params: vec![
make_param("content", TypeRef::Bytes),
make_param("mime_type", TypeRef::String),
],
return_type: TypeRef::Named("ExtractionResult".to_string()),
is_async: true,
is_static: false,
error_type: Some("anyhow::Error".to_string()),
doc: String::new(),
receiver: Some(crate::core::ir::ReceiverKind::Ref),
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: !required,
binding_excluded: false,
binding_exclusion_reason: None,
}
}
#[test]
fn kotlin_android_stub_uses_typed_params_not_any() {
let bridge = make_trait_bridge("TestTrait");
let required_method = make_method_with_params("extractBytes", true);
let methods = [&required_method];
let fixture = make_fixture("my_test_fixture");
let emission = emit_test_backend(&bridge, &methods, &fixture);
let output = format!("{}\n{}", emission.setup_block, emission.arg_expr);
assert!(
!output.contains(": Any"),
"param type must not be `Any`, got:\n{output}"
);
assert!(
output.contains("content: ByteArray"),
"bytes param must map to ByteArray in Kotlin, got:\n{output}"
);
assert!(
output.contains("mimeType: String"),
"string param must map to String in Kotlin, got:\n{output}"
);
assert!(
output.contains("): ExtractionResult"),
"return type must be concrete not Any, got:\n{output}"
);
}
#[test]
fn kotlin_android_stub_uses_fixture_input_name_for_plugin_name() {
let bridge = make_trait_bridge("TestTrait");
let required_method = make_method("process_item", true);
let methods = [&required_method];
let mut fixture = make_fixture("my_fixture_id");
fixture.input = serde_json::json!({ "name": "my-backend-name" });
let emission = emit_test_backend(&bridge, &methods, &fixture);
let output = format!("{}\n{}", emission.setup_block, emission.arg_expr);
assert!(
output.contains("\"my-backend-name\""),
"plugin name must come from fixture.input.name, got:\n{output}"
);
}
}