use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::ApiSurface;
use std::path::PathBuf;
pub(crate) fn scaffold_jni(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let core_crate_dir = config.core_crate_dir();
let jni_crate_name = format!("{}-jni", config.jni_crate_base());
let jni_lib_name = config.jni_lib_name();
let features: Vec<String> = config
.kotlin_android
.as_ref()
.and_then(|k| k.features.as_ref())
.map(|f| f.iter().map(|s| format!("\"{s}\"")).collect())
.unwrap_or_default();
let features_str = if features.is_empty() {
String::new()
} else {
format!(", features = [{}]", features.join(", "))
};
let umbrella_dep_name = &config.name;
let mut dep_lines: Vec<String> = vec![
crate::scaffold::render_core_dep(
umbrella_dep_name,
&format!("../{core_crate_dir}"),
&features_str,
&api.version,
),
"futures-util = \"0.3\"".to_owned(),
"jni = \"0.22\"".to_owned(),
"serde_json = \"1\"".to_owned(),
"tokio = { version = \"1\", features = [\"rt-multi-thread\", \"macros\", \"sync\"] }".to_owned(),
];
dep_lines.sort();
let deps_section = dep_lines.join("\n");
let content = format!(
r#"# Generated by alef. Do not edit by hand.
[package]
name = "{jni_crate_name}"
version.workspace = true
edition.workspace = true
license.workspace = true
# `futures-util`, `serde_json`, and `tokio` are emitted unconditionally below
# so the manifest is stable across regens (they are used when the umbrella
# crate declares async fns, streaming adapters, or JSON-marshalled types),
# but for an umbrella crate that has none of those they are genuinely unused.
# List them here so `cargo machete` doesn't flag the no-async-no-streaming
# case as a real finding.
[package.metadata.cargo-machete]
ignored = ["futures-util", "serde_json", "tokio"]
[lib]
name = "{jni_lib_name}"
crate-type = ["cdylib"]
[dependencies]
{deps_section}
"#,
jni_crate_name = jni_crate_name,
jni_lib_name = jni_lib_name,
deps_section = deps_section,
);
let _ = api;
Ok(vec![GeneratedFile {
path: PathBuf::from(format!("crates/{jni_crate_name}/Cargo.toml")),
content,
generated_header: false,
}])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::NewAlefConfig;
use crate::core::ir::ApiSurface;
fn resolved_one(toml: &str) -> ResolvedCrateConfig {
let cfg: NewAlefConfig = toml::from_str(toml).unwrap();
cfg.resolve().unwrap().remove(0)
}
#[test]
fn scaffold_jni_lib_name_uses_ffi_prefix() {
let config = resolved_one(
r#"
[workspace]
languages = ["kotlin_android", "jni"]
[[crates]]
name = "demo-llm"
sources = ["src/lib.rs"]
[crates.ffi]
prefix = "demoffi"
[crates.kotlin_android]
package = "dev.sample_crate.demo"
namespace = "dev.sample_crate.demo"
"#,
);
let api = ApiSurface::default();
let files = scaffold_jni(&api, &config).unwrap();
let cargo_toml = &files[0].content;
assert!(
cargo_toml.contains("name = \"demoffi_jni\""),
"expected `name = \"demoffi_jni\"` but got:\n{cargo_toml}"
);
assert!(
!cargo_toml.contains("name = \"demo_llm_jni\""),
"cdylib name must not fall back to snake-cased crate name when prefix is set; got:\n{cargo_toml}"
);
}
#[test]
fn scaffold_jni_path_uses_config_name_not_core_crate_dir() {
let config = resolved_one(
r#"
[workspace]
languages = ["kotlin_android", "jni"]
[[crates]]
name = "sample-language-pack"
sources = ["crates/sample-pack-core/src/lib.rs"]
[crates.kotlin_android]
package = "dev.sample_crate.sample_language_pack.android"
namespace = "dev.sample_crate.sample_language_pack.android"
"#,
);
let api = ApiSurface::default();
let files = scaffold_jni(&api, &config).unwrap();
assert_eq!(files.len(), 1);
let path = files[0].path.to_string_lossy();
let cargo_toml = &files[0].content;
assert_eq!(
path, "crates/sample-language-pack-jni/Cargo.toml",
"JNI scaffold path must follow config.name, not core_crate_dir; got: {path}"
);
assert!(
cargo_toml.contains("name = \"sample-language-pack-jni\""),
"[package] name must follow config.name; got:\n{cargo_toml}"
);
assert!(
cargo_toml.contains("sample-language-pack = { path = \"../sample-pack-core\""),
"umbrella dep key must be cargo package name with path = ../<core_crate_dir>; got:\n{cargo_toml}"
);
assert!(
!cargo_toml.contains("sample-pack-core = { path = \"../sample-pack-core\""),
"umbrella dep key must NOT be the directory name; got:\n{cargo_toml}"
);
}
#[test]
fn scaffold_jni_lib_name_defaults_to_snake_case_crate_name() {
let config = resolved_one(
r#"
[workspace]
languages = ["kotlin_android", "jni"]
[[crates]]
name = "plain-pkg"
sources = ["src/lib.rs"]
[crates.kotlin_android]
package = "dev.sample_crate.plain"
namespace = "dev.sample_crate.plain"
"#,
);
let api = ApiSurface::default();
let files = scaffold_jni(&api, &config).unwrap();
let cargo_toml = &files[0].content;
assert!(
cargo_toml.contains("name = \"plain_pkg_jni\""),
"expected `name = \"plain_pkg_jni\"` for default case; got:\n{cargo_toml}"
);
}
#[test]
fn scaffold_jni_crate_dir_override_controls_output_path() {
let config = resolved_one(
r#"
[workspace]
languages = ["kotlin_android", "jni"]
[[crates]]
name = "demo-render-rs"
sources = ["crates/demo-render/src/lib.rs"]
[crates.jni]
crate_dir = "demo-render"
[crates.kotlin_android]
package = "dev.example.demo_render.android"
namespace = "dev.example.demo_render.android"
"#,
);
let api = ApiSurface::default();
let files = scaffold_jni(&api, &config).unwrap();
assert_eq!(files.len(), 1);
let path = files[0].path.to_string_lossy();
let cargo_toml = &files[0].content;
assert_eq!(
path, "crates/demo-render-jni/Cargo.toml",
"JNI scaffold path must follow [crates.jni] crate_dir override; got: {path}"
);
assert!(
cargo_toml.contains("name = \"demo-render-jni\""),
"[package] name must follow crate_dir override; got:\n{cargo_toml}"
);
assert!(
cargo_toml.contains("demo-render-rs = { path = \"../demo-render\""),
"umbrella dep key must be cargo package name, path must be core_crate_dir; got:\n{cargo_toml}"
);
assert!(
!cargo_toml.contains("demo-render = { path = \"../demo-render\""),
"umbrella dep key must NOT be the crate_dir override; got:\n{cargo_toml}"
);
assert!(
!cargo_toml.contains("demo-render-rs-jni"),
"crate name must NOT contain the -rs suffix; got:\n{cargo_toml}"
);
}
#[test]
fn scaffold_jni_dependencies_are_alphabetically_sorted() {
let config = resolved_one(
r#"
[workspace]
languages = ["kotlin_android", "jni"]
[[crates]]
name = "sample_stream"
sources = ["src/lib.rs"]
[crates.kotlin_android]
package = "dev.example.sample_stream"
namespace = "dev.example.sample_stream"
"#,
);
let api = ApiSurface::default();
let files = scaffold_jni(&api, &config).unwrap();
let cargo_toml = &files[0].content;
let mut keys: Vec<&str> = Vec::new();
let mut in_deps = false;
for line in cargo_toml.lines() {
if line.trim_start().starts_with('[') {
in_deps = line.trim() == "[dependencies]";
continue;
}
if in_deps && !line.trim().is_empty() && !line.trim_start().starts_with('#') {
if let Some(key) = line.split('=').next() {
let key = key.trim();
if !key.is_empty() {
keys.push(key);
}
}
}
}
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(
keys, sorted,
"JNI Cargo.toml [dependencies] must be alphabetically sorted; got:\n{keys:?}\nin:\n{cargo_toml}"
);
}
#[test]
fn scaffold_jni_section_order_matches_cargo_sort() {
let config = resolved_one(
r#"
[workspace]
languages = ["kotlin_android", "jni"]
[[crates]]
name = "demo-llm"
sources = ["src/lib.rs"]
[crates.kotlin_android]
package = "dev.sample_crate.demo"
namespace = "dev.sample_crate.demo"
"#,
);
let api = ApiSurface::default();
let files = scaffold_jni(&api, &config).unwrap();
let cargo_toml = &files[0].content;
let pkg = cargo_toml.find("[package]").expect("missing [package]");
let meta = cargo_toml
.find("[package.metadata.cargo-machete]")
.expect("missing [package.metadata.cargo-machete]");
let lib = cargo_toml.find("[lib]").expect("missing [lib]");
let deps = cargo_toml.find("[dependencies]").expect("missing [dependencies]");
assert!(
pkg < meta && meta < lib && lib < deps,
"section order must be [package] < [package.metadata.cargo-machete] < [lib] < [dependencies]; got:\n{cargo_toml}"
);
}
}