I’ve been on a Rust learning journey lately, and it’s had me thinking about how I can consume Rust libraries from existing .NET applications. The .NET team has done much work regarding interoperability during the .NET 6 to .NET 8 era, and .NET 9 seems poised to continue that trend.

In this post, we’ll create a Rust library and consume it from a .NET application. This post assumes you have installed the .NET SDK and Rust SDK (cargo).

A Simple Rust Library

After creating a .NET Solution, I first created a new Rust library using cargo. The command is relatively straightforward.

cargo init --lib calculator

This creates a new calculator library folder with the most critical files: Cargo.toml and lib.rs. Let’s update the Cargo.toml file to produce an artifact that our .NET application can consume, a dynamic library.

[package]
name = "calculator"
version = "0.1.0"
edition = "2021"

[lib]
name="calculator"
crate-type=["dylib"]

[dependencies]
rand = "0.8.5"

I’ve also included the rand dependency for my Rust code later. Running the cargo b command will now produce a library we can copy into our .NET application.

Let’s write some Rust code we’ll consume from our .NET application later.

use rand::prelude::*;
use std::ffi::{c_char, CStr};

#[repr(C)]
#[derive(Debug)]
pub struct Point {
    pub x: u32,
    pub y: u32,
}

#[no_mangle]
pub extern "C" fn add(left: usize, right: usize) -> usize {
    left + right
}

#[no_mangle]
pub extern "C" fn say_hello(name: *const c_char) {
    let c_str = unsafe { CStr::from_ptr(name) };
    println!("Hello, {}", c_str.to_str().unwrap())
}

#[no_mangle]
pub extern "C" fn random_point() -> Point {
    let mut rng = rand::thread_rng();
    Point {
        x: rng.gen::<u32>(),
        y: rng.gen::<u32>(),
    }
}

#[no_mangle]
pub extern "C" fn distance(first: &Point, second: &Point) -> f64 {
    let dx: f64 = (second.x - first.x).into();
    let dy: f64 = (second.y - first.y).into();

    println!("calculating distance...");

    (dx.powf(2.0) + dy.powf(2.0)).sqrt()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn say_hello_test() {
        let _result = say_hello("Khalid".as_ptr() as *const i8);

        assert!(true)
    }

    #[test]
    fn get_random_point() {
        let point: Point = random_point();
        println!("{:?}", point);
        assert!(true);
    }

    #[test]
    fn can_calculate_distance() {
        let one = Point { x: 1, y: 1 };
        let two = Point { x: 2, y: 2 };

        let result = distance(&one, &two);
        println!("distance between {:?} and {:?} is {}", one, two, result);
        assert!((result - 1.414).abs() < 0.001)
    }
}

Notable elements of this Rust code include the following.

  1. The extern keyword adds to the list of functions and types in the foreign functions interfaces (FFI).
  2. no_mangle tells the Rust compiler not to mangle the function’s name so that it can be referenced externally by a .NET application or similar external consumer.
  3. The "C" value after extern tells the Rust compiler to compile to something C-compatible. There are other options here as well.
  4. The repr attribute on Point states that this structure should be stored in memory in a C-compatible way.
  5. All functions should use references to elements, which allows us to marshal information from one technology stack to another with little to no overhead. You need to be careful not to introduce memory leaks here, hence the use of unsafe.

Now let’s modify our .NET application for some Rust fun.

Building Rust from a .NET Project

I’ve used this trick several times across tools, and it works like a charm here with Rust. You can use the MSBuild task of Exec to execute commands.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <LangVersion>preview</LangVersion>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>

  <Target Name="Rust Build " BeforeTargets="Compile">
    <Exec Command="echo 'Configuration: $(Configuration)'"/>
    <Exec Condition="'$(Configuration)' == 'Release'" Command="cargo b --release" WorkingDirectory="./../calculator"/>
    <Exec Condition="'$(Configuration)' != 'Release'" Command="cargo b" WorkingDirectory="./../calculator"/>
  </Target>

  <ItemGroup>
    <PackageReference Include="Spectre.Console" Version="0.48.1-preview.0.38"/>
    <Content Include="../calculator/target/$(Configuration)/libcalculator.dylib">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
  </ItemGroup>


</Project>

You’ll also need to set the AllowUnsafeBlocks element to true, or you won’t be able to access any library. Every time we compile, we’ll build our Rust library and copy it to the project root. From there, we’ll copy the library to our output directory for each build. Depending on your project, feel free to change when the Rust build occurs.

Let’s write some C# code.

Calling Rust from C#

Now, it’s just a matter of giving our C# code something to call. We can do this using .NET’s LibraryImportAttribute.

public partial class Rust
{
    [LibraryImport("libcalculator", EntryPoint = "add")]
    public static partial int Add(int left, int right);

    [LibraryImport("libcalculator", EntryPoint = "say_hello"
        , StringMarshalling = StringMarshalling.Utf8)]
    public static partial void SayHello(string name);

    [LibraryImport("libcalculator", EntryPoint = "random_point")]
    public static partial Point GetRandomPoint();

    [LibraryImport("libcalculator", EntryPoint = "distance")]
    public static partial double Distance(ref Point one, ref Point two);
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
[DebuggerDisplay("({X}, {Y})")]
public struct Point
{
    public UInt32 X;
    public UInt32 Y;
    public override string ToString()
        => $"(x: {X}, y: {Y})";
}

Your interface must match the expectations set by the Rust library. Things like structures, types, and references must match, or you’ll likely get an error code of 139, and the executing application will halt.

using System.Diagnostics;
using System.Runtime.InteropServices;
using Spectre.Console;

var one = new Point { X = 1, Y = 1 };
var two = new Point { X = 2, Y = 2 };

var distance = Rust.Distance(ref one, ref two);
AnsiConsole.MarkupLine($"Distance between [yellow]{one}[/] and [red]{two}[/] is [green]{distance}[/]");

var (left, right) = (2, 2);
var result = Rust.Add(left, right);

AnsiConsole.MarkupLine($"[yellow]{left} + {right}[/] = [green]{result}![/]");
Rust.SayHello("Khalid");
AnsiConsole.MarkupInterpolated($"[red]{Rust.GetRandomPoint()}[/]");

public partial class Rust
{
    [LibraryImport("libcalculator", EntryPoint = "add")]
    public static partial int Add(int left, int right);

    [LibraryImport("libcalculator", EntryPoint = "say_hello"
        , StringMarshalling = StringMarshalling.Utf8)]
    public static partial void SayHello(string name);

    [LibraryImport("libcalculator", EntryPoint = "random_point")]
    public static partial Point GetRandomPoint();

    [LibraryImport("libcalculator", EntryPoint = "distance")]
    public static partial double Distance(ref Point one, ref Point two);
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
[DebuggerDisplay("({X}, {Y})")]
public struct Point
{
    public UInt32 X;
    public UInt32 Y;
    public override string ToString()
        => $"(x: {X}, y: {Y})";
}

LibraryImport works with the DllImportAttribute and is a source generator that correctly handles common memory management issues with marshaling types like string or reference types. You should use LibraryImport instead of writing the DllImport code yourself.

The results are as you’d expect.

calculating distance...
Distance between (x: 1, y: 1) and (x: 2, y: 2) is 1.4142135623730951
2 + 2 = 4!
Hello, Khalid
(x: 2497287370, y: 698299366)

There you have it. We could call Rust code from a .NET Application with different levels of complexity. That’s pretty cool!

Conclusion

I want to thank Jeremy Mill, who wrote a blog post 2017 that helped me learn and experiment with this sample. I hope you found this post helpful, and be sure to share it with friends and colleagues. As always, thanks and cheers.