use crate::core::config::ResolvedCrateConfig;
use crate::core::hash::{self, CommentStyle};
use crate::e2e::config::E2eConfig;
use crate::e2e::escape::sanitize_filename;
use crate::e2e::fixture::Fixture;
use heck::ToUpperCamelCase;
use std::collections::{HashMap, HashSet};
use std::fmt::Write as FmtWrite;
pub(super) fn render_kotlin_env_init(env: &HashMap<String, String>) -> String {
if env.is_empty() {
return String::new();
}
let mut keys: Vec<&String> = env.keys().collect();
keys.sort();
let mut out = String::new();
let _ = writeln!(
out,
" // Suite-level environment defaults from [e2e.env]. JVM OS env is"
);
let _ = writeln!(
out,
" // immutable; System.setProperty is the runtime-mutable analog. Each"
);
let _ = writeln!(
out,
" // entry uses setdefault semantics: only applied when not already set."
);
for key in keys {
let value = &env[key];
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"").replace('$', "\\$");
let _ = writeln!(out, " if (System.getProperty(\"{key}\") == null) {{");
let _ = writeln!(out, " System.setProperty(\"{key}\", \"{escaped}\")");
let _ = writeln!(out, " }}");
}
out
}
pub(super) fn resolve_handle_config_type(
arg: &crate::e2e::config::ArgMapping,
options_type: Option<&str>,
type_defs: &[crate::core::ir::TypeDef],
) -> Option<String> {
if arg.arg_type != "handle" {
return None;
}
if let Some(opts) = options_type {
return Some(opts.to_string());
}
let field_name = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let candidate_from_field = field_name.to_upper_camel_case();
if type_defs.iter().any(|ty| ty.name == candidate_from_field) {
return Some(candidate_from_field);
}
if field_name.contains("config") {
let candidate = format!("{}Config", field_name.to_upper_camel_case());
if type_defs.iter().any(|ty| ty.name == candidate) {
return Some(candidate);
}
if field_name == "config" {
let mut config_types: Vec<_> = type_defs
.iter()
.filter(|ty| ty.name.ends_with("Config"))
.map(|ty| ty.name.clone())
.collect();
if config_types.is_empty() {
return None;
}
config_types.sort_by(|a, b| {
let a_has_underscore = a.contains('_');
let b_has_underscore = b.contains('_');
let a_has_extraction = a.to_lowercase().contains("extraction");
let b_has_extraction = b.to_lowercase().contains("extraction");
if a_has_underscore != b_has_underscore {
return a_has_underscore.cmp(&b_has_underscore);
}
if a_has_extraction != b_has_extraction {
return b_has_extraction.cmp(&a_has_extraction); }
a.cmp(b)
});
return config_types.first().cloned();
}
}
None
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_test_file(
category: &str,
fixtures: &[&Fixture],
class_name: &str,
function_name: &str,
kotlin_pkg_id: &str,
result_var: &str,
args: &[crate::e2e::config::ArgMapping],
options_type: Option<&str>,
result_is_simple: bool,
e2e_config: &E2eConfig,
type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
) -> String {
render_test_file_inner(
category,
fixtures,
class_name,
function_name,
kotlin_pkg_id,
result_var,
args,
options_type,
result_is_simple,
e2e_config,
type_enum_fields,
false,
config,
type_defs,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_test_file_android(
category: &str,
fixtures: &[&Fixture],
class_name: &str,
function_name: &str,
kotlin_pkg_id: &str,
result_var: &str,
args: &[crate::e2e::config::ArgMapping],
options_type: Option<&str>,
result_is_simple: bool,
e2e_config: &E2eConfig,
type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
) -> String {
render_test_file_inner(
category,
fixtures,
class_name,
function_name,
kotlin_pkg_id,
result_var,
args,
options_type,
result_is_simple,
e2e_config,
type_enum_fields,
true,
config,
type_defs,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_test_file_inner(
category: &str,
fixtures: &[&Fixture],
class_name: &str,
function_name: &str,
kotlin_pkg_id: &str,
result_var: &str,
args: &[crate::e2e::config::ArgMapping],
options_type: Option<&str>,
result_is_simple: bool,
e2e_config: &E2eConfig,
type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
kotlin_android_style: bool,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
) -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::DoubleSlash));
let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
let (import_path, simple_class) = if class_name.contains('.') {
let simple = class_name.rsplit('.').next().unwrap_or(class_name);
(class_name, simple)
} else {
("", class_name)
};
let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
let _ = writeln!(out);
let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
let has_client_factory_fixtures = fixtures.iter().any(|f| {
if f.is_http_test() {
return false;
}
let cc =
e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
let per_call_factory = cc.overrides.get("kotlin").and_then(|o| o.client_factory.as_deref());
let global_factory = e2e_config
.call
.overrides
.get("kotlin")
.and_then(|o| o.client_factory.as_deref());
per_call_factory.or(global_factory).is_some()
});
let mut per_fixture_options_types: HashSet<String> = HashSet::new();
for f in fixtures.iter() {
let cc =
e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
let call_overrides = cc.overrides.get("kotlin");
let effective_opts: Option<String> = call_overrides
.and_then(|o| o.options_type.clone())
.or_else(|| options_type.map(|s| s.to_string()))
.or_else(|| {
for cand in ["kotlin", "csharp", "c", "go", "php", "python"] {
if let Some(o) = cc.overrides.get(cand) {
if let Some(t) = &o.options_type {
return Some(t.clone());
}
}
}
None
});
if let Some(opts) = effective_opts {
let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
let needs_opts_type = fixture_args.iter().any(|arg| {
if arg.arg_type != "json_object" {
return false;
}
let v = crate::e2e::codegen::resolve_field(&f.input, &arg.field);
!v.is_null() || arg.optional
});
if needs_opts_type {
per_fixture_options_types.insert(opts.to_string());
}
}
}
let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
let mut trait_bridge_classes: HashSet<String> = HashSet::new();
let mut plugin_interfaces: HashSet<String> = HashSet::new();
if kotlin_android_style {
for f in fixtures.iter() {
let cc = e2e_config.resolve_call_for_fixture(
f.call.as_deref(),
&f.id,
&f.resolved_category(),
&f.tags,
&f.input,
);
if let Some(overrides) = cc.overrides.get("kotlin_android") {
if let Some(bridge_class) = &overrides.class {
trait_bridge_classes.insert(bridge_class.clone());
match bridge_class.as_str() {
"DocumentExtractorBridge" => {
plugin_interfaces.insert("IDocumentExtractor".to_string());
}
"EmbeddingBackendBridge" => {
plugin_interfaces.insert("IEmbeddingBackend".to_string());
}
"OcrBackendBridge" => {
plugin_interfaces.insert("IOcrBackend".to_string());
}
"PostProcessorBridge" => {
plugin_interfaces.insert("IPostProcessor".to_string());
}
"RendererBridge" => {
plugin_interfaces.insert("IRenderer".to_string());
}
"ValidatorBridge" => {
plugin_interfaces.insert("IValidator".to_string());
}
_ => {}
}
}
}
}
}
let mut element_type_classes: HashSet<String> = HashSet::new();
for f in fixtures.iter() {
let cc =
e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
let lang_for_recipe = if kotlin_android_style {
"kotlin_android"
} else {
"kotlin"
};
let recipe = crate::e2e::codegen::recipe::ResolvedE2eCallRecipe::resolve(lang_for_recipe, f, cc, type_defs);
for a in recipe.args.iter() {
if a.arg_type == "json_object" {
if let Some(element_type) = a.element_type.as_deref() {
const KOTLIN_BUILTINS: &[&str] = &[
"String", "Int", "Long", "Short", "Byte", "Boolean", "Char", "Float", "Double", "Unit", "Any",
"Nothing", "List", "Map", "Set",
];
if !KOTLIN_BUILTINS.contains(&element_type) {
element_type_classes.insert(element_type.to_string());
}
}
}
}
}
let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
let cc =
e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
let lang_for_recipe = if kotlin_android_style {
"kotlin_android"
} else {
"kotlin"
};
let recipe = crate::e2e::codegen::recipe::ResolvedE2eCallRecipe::resolve(lang_for_recipe, f, cc, type_defs);
recipe.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
let v = crate::e2e::codegen::resolve_field(&f.input, &a.field);
!(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
})
});
let needs_object_mapper_for_array_elements = fixtures.iter().any(|f| {
let cc =
e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
let lang_for_recipe = if kotlin_android_style {
"kotlin_android"
} else {
"kotlin"
};
let recipe = crate::e2e::codegen::recipe::ResolvedE2eCallRecipe::resolve(lang_for_recipe, f, cc, type_defs);
recipe.args.iter().any(|a| {
a.arg_type == "json_object"
&& a.element_type.is_some()
&& !crate::e2e::codegen::resolve_field(&f.input, &a.field).is_null()
})
});
let needs_object_mapper = needs_object_mapper_for_options
|| needs_object_mapper_for_handle
|| needs_object_mapper_for_array_elements
|| has_http_fixtures;
let has_streaming_fixtures = kotlin_android_style
&& fixtures.iter().any(|f| {
if f.is_http_test() {
return false;
}
let cc = e2e_config.resolve_call_for_fixture(
f.call.as_deref(),
&f.id,
&f.resolved_category(),
&f.tags,
&f.input,
);
crate::e2e::codegen::streaming_assertions::resolve_is_streaming(f, cc.streaming_enabled())
});
let _ = writeln!(out, "import org.junit.jupiter.api.Test");
let _ = writeln!(out, "import kotlin.test.assertEquals");
let _ = writeln!(out, "import kotlin.test.assertTrue");
let _ = writeln!(out, "import kotlin.test.assertFalse");
let _ = writeln!(out, "import kotlin.test.assertFailsWith");
if has_client_factory_fixtures || kotlin_android_style {
let _ = writeln!(out, "import kotlinx.coroutines.runBlocking");
}
if has_streaming_fixtures {
let _ = writeln!(out, "import kotlinx.coroutines.flow.toList");
}
let binding_pkg_for_imports: String = if !import_path.is_empty() {
import_path
.rsplit_once('.')
.map(|(p, _)| p.to_string())
.unwrap_or_else(|| kotlin_pkg_id.to_string())
} else {
kotlin_pkg_id.to_string()
};
let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
if has_call_fixtures {
if !import_path.is_empty() {
let _ = writeln!(out, "import {import_path}");
} else if !class_name.is_empty() {
let _ = writeln!(out, "import {binding_pkg_for_imports}.{class_name}");
}
}
let needs_format_metadata_import = fixtures.iter().any(|fixture| {
fixture.assertions.iter().any(|assertion| {
assertion
.field
.as_deref()
.is_some_and(|field| super::discriminated::parse_discriminated_union_access(field).is_some())
})
});
if has_call_fixtures && needs_format_metadata_import {
let _ = writeln!(out, "import {binding_pkg_for_imports}.FormatMetadata");
}
if needs_object_mapper {
let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
if kotlin_android_style {
let _ = writeln!(out, "import com.fasterxml.jackson.module.kotlin.registerKotlinModule");
}
}
if has_call_fixtures {
let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
sorted_opts.sort();
for opts_type in sorted_opts {
let _ = writeln!(out, "import {binding_pkg_for_imports}.{opts_type}");
}
}
let mut sorted_elements: Vec<&String> = element_type_classes.iter().collect();
sorted_elements.sort();
for element_type in sorted_elements {
let _ = writeln!(out, "import {binding_pkg_for_imports}.{element_type}");
}
if !trait_bridge_classes.is_empty() {
let mut sorted_bridges: Vec<&String> = trait_bridge_classes.iter().collect();
sorted_bridges.sort();
for bridge_class in sorted_bridges {
let _ = writeln!(out, "import {binding_pkg_for_imports}.{bridge_class}");
}
}
if !plugin_interfaces.is_empty() {
let mut sorted_interfaces: Vec<&String> = plugin_interfaces.iter().collect();
sorted_interfaces.sort();
for iface in sorted_interfaces {
let _ = writeln!(out, "import {binding_pkg_for_imports}.{iface}");
}
let _ = writeln!(out, "import {binding_pkg_for_imports}.*");
}
let mut handle_config_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for f in fixtures.iter() {
let cc =
e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
let lang_for_recipe = if kotlin_android_style {
"kotlin_android"
} else {
"kotlin"
};
let recipe = crate::e2e::codegen::recipe::ResolvedE2eCallRecipe::resolve(lang_for_recipe, f, cc, type_defs);
for arg in recipe.args.iter().filter(|arg| arg.arg_type == "handle") {
let value = crate::e2e::codegen::resolve_field(&f.input, &arg.field);
if value.is_null() || value.is_object() && value.as_object().is_some_and(|o| o.is_empty()) {
continue;
}
if let Some(config_type) = resolve_handle_config_type(arg, recipe.options_type, type_defs) {
handle_config_types.insert(config_type);
}
}
}
for config_type in handle_config_types {
let _ = writeln!(out, "import {binding_pkg_for_imports}.{config_type}");
}
let _ = writeln!(out);
let _ = writeln!(out, "/** E2e tests for category: {category}. */");
let _ = writeln!(out, "class {test_class_name} {{");
let needs_companion = needs_object_mapper || kotlin_android_style;
if needs_companion {
let _ = writeln!(out);
let _ = writeln!(out, " companion object {{");
if kotlin_android_style {
let jni_lib_name = config.jni_lib_name();
let _ = writeln!(out, " init {{");
let env_block = render_kotlin_env_init(&e2e_config.env);
if !env_block.is_empty() {
out.push_str(&env_block);
}
let _ = writeln!(out, " try {{");
let _ = writeln!(out, " System.loadLibrary(\"{jni_lib_name}\")");
let _ = writeln!(out, " }} catch (e: UnsatisfiedLinkError) {{");
let _ = writeln!(
out,
" System.err.println(\"Failed to load {jni_lib_name} library: ${{e.message}}\")"
);
let _ = writeln!(
out,
" val libPath = System.getProperty(\"java.library.path\")"
);
let _ = writeln!(
out,
" System.err.println(\"java.library.path: $libPath\")"
);
let _ = writeln!(out, " throw e");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
}
if needs_object_mapper {
let kotlin_module_call = if kotlin_android_style {
".registerKotlinModule()"
} else {
""
};
let _ = writeln!(
out,
" private val MAPPER = ObjectMapper().registerModule(Jdk8Module()){kotlin_module_call}.setPropertyNamingStrategy(com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE)"
);
}
let _ = writeln!(out, " }}");
}
for fixture in fixtures {
super::test_method::render_test_method(
&mut out,
fixture,
simple_class,
function_name,
result_var,
args,
options_type,
result_is_simple,
e2e_config,
type_enum_fields,
kotlin_android_style,
config,
type_defs,
);
let _ = writeln!(out);
}
let _ = writeln!(out, "}}");
out
}
pub(super) 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,
}
}
#[cfg(test)]
mod env_init_tests {
use super::render_kotlin_env_init;
use std::collections::HashMap;
#[test]
fn render_kotlin_env_init_emits_setdefault_with_sorted_keys() {
let mut env = HashMap::new();
env.insert("E2E_ALLOW_PRIVATE_NETWORK".to_string(), "true".to_string());
env.insert("ALEF_FOO".to_string(), "bar".to_string());
let block = render_kotlin_env_init(&env);
assert!(
block.contains("if (System.getProperty(\"ALEF_FOO\") == null) {"),
"got: {block}"
);
assert!(
block.contains("System.setProperty(\"ALEF_FOO\", \"bar\")"),
"got: {block}"
);
assert!(
block.contains("if (System.getProperty(\"E2E_ALLOW_PRIVATE_NETWORK\") == null) {"),
"got: {block}"
);
assert!(
block.contains("System.setProperty(\"E2E_ALLOW_PRIVATE_NETWORK\", \"true\")"),
"got: {block}"
);
let alef_pos = block.find("ALEF_FOO").unwrap();
let e2e_pos = block.find("E2E_ALLOW_PRIVATE_NETWORK").unwrap();
assert!(alef_pos < e2e_pos, "keys must be sorted alphabetically; got: {block}");
}
#[test]
fn render_kotlin_env_init_empty_when_no_env_configured() {
let env = HashMap::new();
assert_eq!(render_kotlin_env_init(&env), "");
}
#[test]
fn render_kotlin_env_init_escapes_quotes_and_dollar() {
let mut env = HashMap::new();
env.insert("Q".to_string(), "a\"b$c\\d".to_string());
let block = render_kotlin_env_init(&env);
assert!(
block.contains("System.setProperty(\"Q\", \"a\\\"b\\$c\\\\d\")"),
"got: {block}"
);
}
}