Skip to main content
Version: Rustine

The Cargo plugin

Basic usage​

The Cargo plugin is responsible for building and linking the Rust library to your Kotlin project. You can use it even when you are not using UniFFI. If the Cargo.toml is located in the project root, you can simply apply the dev.lidory.rustine.cargo the plugin.

plugins {
kotlin("multiplatform")
id("dev.lidory.rustine.cargo") version "1.0.0"
}

Configuring Cargo package not in the project root​

If the Cargo package is located in another directory, you can configure the path in the cargo {} block.

cargo {
// The Cargo package is located in a `rust` subdirectory.
packageDirectory = layout.projectDirectory.dir("rust")
}

Since searching Cargo.toml is done by cargo locate-project, it still works even if you set packageDirectory to a subdirectory, but it is not recommended.

cargo {
// This works
packageDirectory = layout.projectDirectory.dir("rust/src")
}

The Cargo plugin only builds for required platforms​

Cargo build tasks are configured as the corresponding Kotlin target is added in the kotlin {} block. For example, if your project doesn't target Android, the Cargo plugin won't configure the Android build task as well.

cargo {
builds.android {
println("foo") // not executed
}
}

kotlin {
// The plugin will react to the targets definition
jvm()
linuxX64()
}

The Cargo plugin scans all the Rust dependencies using cargo metadata. If you modify Rust source files including those in dependencies defined in the Cargo manifest, the Cargo plugin will rebuild the Cargo project.

Building for Android​

The Cargo plugin supports com.android.application and com.android.library modules using the built-in Kotlin support of the Android Gradle plugin (AGP), and Kotlin Multiplatform modules using com.android.kotlin.multiplatform.library. See Requirements for details.

The Rust libraries are added to each Android variant using the AGP Variant API, so you don't have to configure jniLibs directories yourself. The Cargo plugin registers a copyCargoJniLibs<variant name> task for each Android variant, which collects the Rust libraries built for the variant. In application and library modules, the Cargo profile of each variant is determined by its build type. See Be careful of the build variant used during publishing for details.

Changing the NDK version and the ABIs in application and library modules​

In com.android.application and com.android.library modules, the Cargo plugin automatically determines the SDK and the NDK to use based on the property values of the android {} block. AGP 9.1 uses NDK 28.2.13676358 by default. If that version is not installed, the latest NDK installed in the SDK is used. To use a different NDK version, set ndkVersion to that version.

android {
ndkVersion = "29.0.14206865"
}

The Cargo plugin also automatically determines the ABI to build based on the value of android.defaultConfig.ndk.abiFilters. If you don't want to build for x86 or x86_64, set this to ["arm64-v8a", "armeabi-v7a"].

android {
defaultConfig {
ndk.abiFilters += setOf("arm64-v8a", "armeabi-v7a")
}
}

Changing the NDK version and the ABIs in Kotlin Multiplatform library modules​

com.android.kotlin.multiplatform.library doesn't have properties like ndkVersion or ndk.abiFilters in the kotlin { android {} } block. The Cargo plugin uses the latest NDK installed in the SDK (<SDK root>/ndk/<NDK version>). The ndkVersion and ndkPath properties of the com.android.settings plugin are not used by the Cargo plugin.

By default, the Cargo plugin builds for all Android ABIs. To exclude ABIs, use the embedRustLibrary property.

import rustine.gradle.cargo.dsl.*
import rustine.gradle.rust.targets.RustAndroidTarget

cargo {
builds.android {
// Build for arm64-v8a and armeabi-v7a only
embedRustLibrary = rustTarget == RustAndroidTarget.Arm64 ||
rustTarget == RustAndroidTarget.ArmV7
}
}

💡 builds.android {} is an extension function defined in the rustine.gradle.cargo.dsl package. Make sure it is imported, especially in Kotlin Multiplatform modules, where android refers to other declarations as well.

Changing the environment variables used during the build​

The Cargo plugin automatically configures environment variables like ANDROID_HOME or CC_<target> for you, but if you need finer control, you can directly configure the properties of the build task. The build task is accessible in the builds {} block.

import rustine.gradle.cargo.dsl.*

cargo {
builds {
// Configure Android builds
android {
debug.buildTaskProvider.configure {
additionalEnvironment.put("CLANG", "/path/to/clang")
}
}
// You can configure for other targets as well
appleMobile {}
desktop {}
jvm {}
mobile {}
native {}
posix {}
mingw {}
linux {}
macos {}
windows {}
}
}

Configuring the platforms used by the JVM target​

For JVM builds, the Cargo plugin tries to build all the targets, whether the required toolchains are installed on the current system or not. The list of such targets by the build host is as follows.

TargetsWindowsmacOSLinux
Android✅✅✅
Apple Mobile❌✅❌
MinGW✅✅✅
macOS❌✅❌
Linux✅✅✅
Visual C++✅❌❌

Controlling the targets to build​

To build for specific targets only, you can configure that using the embedRustLibrary property. For example, to build a shared library for the current build host only, set this property to rustTarget == RustineHost.current.rustTarget.

import rustine.gradle.RustineHost
import rustine.gradle.cargo.dsl.*

cargo {
builds.jvm {
embedRustLibrary = (rustTarget == RustineHost.current.rustTarget)
}
}

On Windows, both MinGW and Visual C++ can generate DLLs. By default, the Cargo plugin doesn't invoke the MinGW build for JVM on Windows since Visual C++ is available. To override this behavior, use the embedRustLibrary property like the following. Note that MinGW Windows on ARM is not supported by Rustine.

import rustine.gradle.RustineHost
import rustine.gradle.cargo.dsl.*
import rustine.gradle.rust.targets.RustWindowsTarget

cargo {
builds.jvm {
if (RustineHost.Platform.Windows.isCurrent) {
when (rustTarget) {
RustWindowsTarget.X64 -> embedRustLibrary = false
RustPosixTarget.MinGWX64 -> embedRustLibrary = true
else -> {}
}
}
}
}

embedRustLibrary is also used when you use the external types feature in your project. Rust statically links all the crates unless you specify the library crate's kind as dylib. So, the final Kotlin library does not have to include shared libraries built from every crate. Suppose you have two crates, foo, and bar, where foo exposes the external types and bar uses types in foo. Since when building bar.dll, libbar.dylib, or libbar.so, the foo crate is also included in bar, you don't have to put foo.dll, libfoo.dylib, or libfoo.so inside your Kotlin library. So, to configure that, put the followings in foo/build.gradle.kts:

cargo {
builds.android {
embedRustLibrary = false
}
builds.jvm {
embedRustLibrary = false
}
}

and in foo/uniffi.toml:

# The cdylib_name used in `bar/uniffi.toml`
cdylib_name = "bar"

The JVM loadIndirect() function in the bindings allow users to override the cdylib_name value using the uniffi.component.<namespace name>.libraryOverride system property as well. See the :tests:uniffi:ext-types:ext-types test to see how this works.

Publishing JAR artifacts containing the Rust dynamic libraries​

Since the dynamic libraries built with Cargo are packaged as separate JAR files with different classifiers, you can publish the library for each platform on a different build machine. For example, you can configure the CI to build and publish for Windows and Linux on Windows and macOS on macOS. The Java part is platform-agnostic. You can publish it on any platform where you can use Java.

To override the JAR classifier used by each platform, use the jarTaskProvider property. The archiveClassifier defaults to rustTarget.jnaResourcePrefix + "-debug" for debug builds and rustTarget.jnaResourcePrefix for release builds.

cargo {
builds {
macos {
debug.jarTaskProvider.configure {
// Set the JAR classifier to darwin-<arch>-unoptimized
archiveClassifier = rustTarget.jnaResourcePrefix + "-unoptimized"
}
}
}
}

When you're developing a Kotlin Multiplatform project and have applied the maven-publish Gradle plugin, The JAR tasks are automatically added to the publication. For more details about using maven-publish with Kotlin Multiplatform, please refer here. To disable this behavior, use the publishJvmArtifacts property.

cargo {
publishJvmArtifacts = false
}

To use the published Rust dynamic library JAR artifacts, you have to specify the classifiers.

kotlin {
sourceSets {
val jvmMain by creating {
dependencies {
runtimeOnly(dependencies.variantOf("com.example.foo:foo-jvm:0.1.0") {
classifier("darwin-aarch64")
})
// You can use the above with version catalogs as well
runtimeOnly(dependencies.variantOf(libs.example.foo) {
classifier("darwin-aarch64")
})
// Add for the other platforms you're targeting as well
runtimeOnly(dependencies.variantOf(libs.example.foo) {
classifier("win32-x86-64")
})
}
}
}
}

Configuring the platforms used by Android local unit tests​

Android local unit tests requires JVM targets to be built, as they run in the host machine's JVM. The Cargo plugin automatically copies the Rust shared library targeting the host machine into Android local unit tests. It also finds projects that depend on the project using the Cargo plugin, and the Rust library will be copied to all projects that directly or indirectly use the Cargo project. If you want to include shared library built for a different platform, you can control that using the androidUnitTest property.

import rustine.gradle.cargo.dsl.*
import rustine.gradle.rust.targets.RustWindowsTarget

cargo {
builds.jvm {
// Use Visual C++ X64 for Android local unit tests
androidUnitTest = (rustTarget == RustWindowsTarget.X64)
}
}

kotlin {
jvm()
android {
withHostTest {}
}
}

Where the Rust library is added depends on the kind of the Android module.

  • In com.android.application and com.android.library modules, the Rust library is added to the local unit tests of each build type using the test<build type>RuntimeOnly configurations (e.g., testDebugRuntimeOnly). The debug Rust library is used when the name of the build type contains debug, and the release Rust library is used otherwise. Note that AGP 9 enables local unit tests only for the debug build type by default.
  • In com.android.kotlin.multiplatform.library modules, local unit tests are called host tests, and their source set is androidHostTest instead of androidUnitTest. Host tests must be enabled using kotlin { android { withHostTest {} } }. The debug Rust library is added to host tests.

Local unit tests are successfully built even if there are no builds with androidUnitTest enabled, but you will encounter a runtime error when you invoke a Rust function from Kotlin.

When you build or publish your Rust Android library separately and run Android local unit tests in another build, you also have to reference the JAR artifact containing the dynamic library built with Cargo. To find the JAR task generating such artifacts, see Publishing JAR artifacts containing the Rust dynamic libraries.

When you want to build the Rust dynamic library JAR locally, you can reference the JAR file using the files or the fileTree functions.

// Kotlin Multiplatform (com.android.kotlin.multiplatform.library)
kotlin {
sourceSets {
getByName("androidHostTest") {
dependencies {
// runtimeOnly(files("<project name>-<JVM target name>-<version>-<classifier>.jar"))
runtimeOnly(files("foo-jvm-0.1.0-darwin-aarch64.jar"))
// You can add multiple invocations of runtimeOnly(...)
runtimeOnly(files("foo-jvm-0.1.0-win32-x86-64.jar"))
runtimeOnly("net.java.dev.jna:jna:5.18.1") // required to run if you're using UniFFI
}
}
}
}

// Android application or library (com.android.application or com.android.library)
dependencies {
testRuntimeOnly(files("foo-jvm-0.1.0-darwin-aarch64.jar"))
testRuntimeOnly("net.java.dev.jna:jna:5.18.1") // required to run if you're using UniFFI
}

If you want to automate this process, you can publish the JVM version of your library and use it from the local unit test. For example, To publish your library to the local Maven repository on your system, run the publishToMavenLocal task.

./gradlew :your:project:publishToMavenLocal

In the local repository which is located in ~/.m2, you will see that multiple artifacts including <project name> and <project name>-<JVM target name> are generated. To reference it, register the mavenLocal() repository and put the artifact name to runtimeOnly().

repositories {
mavenLocal()
// ...
}

kotlin {
sourceSets {
// Use the `testRuntimeOnly` configuration in the top-level `dependencies {}` block in
// Android application or library modules.
getByName("androidHostTest") {
dependencies {
// implementation("<group name>:<project name>-<JVM target name>:<version>")
runtimeOnly(dependencies.variantOf("com.example.foo:foo-jvm:0.1.0") {
// The archive classifier, which defaults to `rustTarget.jnaResourcePrefix`.
classifier("darwin-aarch64")
})
runtimeOnly("net.java.dev.jna:jna:5.18.1") // required to run if you're using UniFFI
}
}
}
}

Configuring external dynamic libraries your Rust code depends on​

If your Rust library is dependent on other shared libraries, you have to ensure that they are also available during runtime. For JVM and Android builds, you can use the dynamicLibraries and the dynamicLibrarySearchPaths properties. The specified libraries will be embedded into the resulting JAR or the Android bundle.

cargo {
builds.android {
// Copies libaaudio.so and libc++_shared.so from NDK
dynamicLibraries.addAll("aaudio", "c++_shared")
}
builds.jvm {
// Copies libmyaudio.so or myaudio.dll
dynamicLibraries.addAll("myaudio")
}
}

The Cargo plugin will automatically configure several directories as the paths to locate the dynamic libraries.

  • The Cargo build output directory (e.g., target/<triplet>/<profile>).
  • The Cargo build script output directory (e.g., target/<triplet>/<profile>/build/<crate>-<hash>/out). Use the OUT_DIR environment variable to determine the value of this path in your build script.
  • (Android only) The NDK library directories (e.g., <NDK ROOT>/toolchains/llvm/prebuilt/<host>/sysroot/usr/lib/<triplet>[/<API Level>]). These directories contain C++ runtime libraries like libc++_shared.so and NDK libraries like libaaudio.so.

If your build system uses other directories, add their paths to dynamicLibrarySearchPaths.

cargo {
builds.jvm {
dynamicLibrarySearchPaths.add(File("/path/to/libraries"))
dynamicLibraries.add("foo")
}
}

Configuring Cargo to use different Cargo features or build profiles​

While it is unusual to use separate configurations for debugging and releasing on Java, you should care about the build variant when you're publishing an application or a library written in Rust. Rustine handles this discrepancy using profiles and variants. If you want to use customized Cargo profiles or different Cargo features for different Cargo profiles, you can configure them using these APIs.

Rustine provides two variants: Variant.Debug and Variant.Release. You can then specify the Cargo profile to use for each variant in the cargo {} block.

import rustine.gradle.cargo.profiles.CargoProfile

cargo {
debug.profile = CargoProfile("my-debug")
release.profile = CargoProfile.Bench
}

If you want to use different Cargo features, you can configure them in the cargo {}, the debug {}, or the release {} blocks.

cargo {
features.addAll("foo")
debug {
// Use "foo", "logging" for debug builds
features.addAll("logging")
}
release {
// Use "foo", "app-integrity-checks" for release builds
features.addAll("app-integrity-checks")
}
}

features are inherited from the outer block to the inner block. To override this behavior in the inner block, use .set() or the = operator overloading.

cargo {
features.addAll("foo")
debug {
// Use "foo", "logging" for debug builds
features.addAll("logging")
}
release {
// Use "app-integrity-checks" (not "foo"!) for release builds
features.set(setOf("app-integrity-checks"))
}
}

For configurations applied to all variants, you can use the variants {} block.

cargo {
variants {
features.addAll("another-feature")
}
}

Be careful of the build variant used during publishing​

💡 In com.android.application and com.android.library modules, the Android Gradle plugin supports multiple build variants, and Rustine automatically chooses the Cargo build by the build type of each Android variant. When the name of the build type contains debug (ignoring case), the debug build is used. Otherwise, the release build is used. For example, the debug build type uses the debug build, and the release and the custom staging build types use the release build. Product flavors don't affect this.

For scenarios where the variant to use can't be chosen automatically, Rustine provides jvmVariant, jvmPublishingVariant, nativeVariant, and androidVariant. These properties can be configured inside the cargo {} or the builds {} blocks.

import rustine.gradle.Variant
import rustine.gradle.cargo.dsl.*

cargo {
jvmVariant = Variant.Release
jvmPublishingVariant = Variant.Release
nativeVariant = Variant.Debug
androidVariant = Variant.Release
builds {
jvm {
jvmVariant = Variant.Release
jvmPublishingVariant = Variant.Release
}
native {
nativeVariant = Variant.Debug
}
android {
androidVariant = Variant.Release
}
}
}

jvmVariant designates the variant to use when you hit the run button inside the IDE. It defaults to Variant.Debug. If you're using Rustine in an application project or a library project directly referenced by an application project, jvmVariant is used, which means you might be building and releasing the debug version of your application. On the other hand, jvmPublishingVariant is used when you publish a library. It defaults to Variant.Release. The publishing task depends on the JAR task selected by jvmPublishingVariant.

Unlike the JVM variant properties, native targets only have nativeVariant. It defaults to Variant.Debug. When you invoke Gradle from Xcode, Rustine will read environment variables set by Xcode and automatically determine the value for nativeVariant. When you build for Windows or Linux, where such environment variables are not available, you should manually determine the value for nativeVariant. Even when you build for Apple platforms, if you're just publishing a library, you should be careful of the value of nativeVariant, as it doesn't use Xcode.

androidVariant is used in Kotlin Multiplatform modules using the com.android.kotlin.multiplatform.library plugin. As this plugin doesn't support build types, the Android library has a single variant, and Rustine can't choose between the debug build and the release build by the Android variant. androidVariant determines the Cargo build embedded into the Android library. By default, Rustine looks at the tasks requested in the command line of the root build, including when the library is in an included build: when the name of a requested task contains release (ignoring case) or starts with publish, as in ./gradlew :androidApp:assembleRelease, ./gradlew :androidApp:bundleRelease, or ./gradlew publishToMavenLocal, androidVariant defaults to Variant.Release. Otherwise, it defaults to Variant.Debug. This means tasks requested in other ways, such as ./gradlew build, ./gradlew assemble, or abbreviated task names like ./gradlew aR, use the debug Rust library even for the release version of an application. Set androidVariant explicitly in such cases. androidVariant is ignored in com.android.application and com.android.library modules.

You can use Gradle properties to control these values.

import rustine.gradle.Variant

cargo {
// When you're using Rustine in an application project
jvmVariant = Variant(findProperty("my.project.jvm.variant")?.toString() ?: "debug")
// When you build for native targets without Xcode
nativeVariant = Variant(findProperty("my.project.native.variant")?.toString() ?: "debug")
// When you're using Rustine in a Kotlin Multiplatform library targeting Android. Set it only
// when the property is given, to keep the default based on the requested tasks otherwise.
findProperty("my.project.android.variant")?.let { androidVariant = Variant(it.toString()) }
}

For example, to build every variant of an application module depending on a Kotlin Multiplatform library with the release Rust library, pass the property to Gradle. When androidVariant is set explicitly like this, the requested task names are not considered.

./gradlew :androidApp:assemble -Pmy.project.android.variant=release

Disabling automatic installation of pre-built standard libraries​

By default, the Cargo plugin installs the pre-built standard library of the specified target via the rustup target add command before initiating a build. For example, if your library targets AArch64 Android, the plugin will run rustup target add aarch64-linux-android before building.

While this behavior is convenient in most scenarios, some users using a custom target may have trouble setting their build. To address such cases, the Cargo plugin offers the installTargetBeforeBuild property, which defaults to true. If you set it to false, the plugin will skip the rustup target add step before building. You can configure this property globally or on a per-target basis, as shown below:

cargo {
// Disable `rustup target add` for all targets
installTargetBeforeBuild = false
// Alternatively, disable `rustup target add` for a specific target (e.g. Android)
builds.android {
// installTargetBeforeBuild is inherited from the one in the `cargo {}` block
installTargetBeforeBuild = false
}
}

Enabling the nightly mode and building tier 3 Rust targets​

Some targets like tvOS and watchOS are tier 3 in the Rust world (they are tier 2 on the Kotlin side). Pre-built standard libraries are not available for these targets, and the Cargo plugin does not invoke the rustup target add command for them. To use the standard library, you must pass the -Zbuild-std flag to the cargo build command ( See here for the official documentation). Since this flag is available only on the nightly channel, you should tell the Cargo plugin to use the nightly compiler to compile the standard library.

First, download the source code of the standard library using the following command.

rustup component add rust-src --toolchain nightly

UniFFI 0.32 and its dependencies require Rust 1.88, so the nightly toolchain must be at least as recent as Rust 1.88. Run rustup update nightly to update it. Rustine tests the tvOS and watchOS targets with nightly-2025-07-15 (1.90.0-nightly), a nightly of the Rust version it is built with.

To get the tier of a RustTarget, you can use the fun RustTarget.tier(version: String): Int function. We can instruct Cargo to build the standard library for tier 3 targets only with it.

cargo {
builds.appleMobile {
variants {
if (rustTarget.tier(project.rustVersion.get()) >= 3) {
buildTaskProvider.configure {
// Pass +nightly to `cargo rustc` (or `cargo build`) to use `-Zbuild-std`.
nightly = true
// Make Cargo build the standard library
extraArguments.add("-Zbuild-std")
}
// You can configure for the check task as well
checkTaskProvider.configure {
nightly = true
extraArguments.add("-Zbuild-std")
}
}
}
}
}