Skip to main content

Rust project organization

Beginner
Rust

Overview

When a new Rust project is created with the command:

dfx new example --type=rust

The following project structure is generated:

Cargo.lock
Cargo.toml
dfx.json
package.json
src
├── example_backend
│   ├── Cargo.toml
│   ├── example_backend.did
│   └── src
│       └── lib.rs
└── example_frontend
    ├── assets
    │   ├── favicon.ico
    │   ├── logo2.svg
    │   ├── main.css
    │   └── sample-asset.txt
    └── src
        ├── index.html
        └── index.js
webpack.config.js

In this structure, you can see the backend canister; in this case, example_backend contains the following components:

│   ├── Cargo.toml //
│   ├── example_backend.did // The backend canister's Candid file.
│   └── src
│       └── lib.rs // The file containing your Rust smart contract.

dfx.json

One of the template files included in your project directory is a default dfx.json configuration file. This file contains settings required to build a project for ICP much like the Cargo.toml file provides build and package management configuration details for Rust programs.

The configuration file should look like this:

dfx.json
{
  "canisters": {
    "example_backend": {
      "candid": "src/example_backend/example_backend.did",
      "package": "example_backend",
      "type": "rust"
    },
    "example_frontend": {
      "dependencies": [
        "example_backend"
      ],
      "frontend": {
        "entrypoint": "src/example_frontend/src/index.html"
      },
      "source": [
        "src/example_frontend/assets",
        "dist/example_frontend/"
      ],
      "type": "assets"
    }
  },
  "defaults": {
    "build": {
      "args": "",
      "packtool": ""
    }
  },
  "version": 1
}

Notice that under the canisters key, you have some default settings for the example_backend canister.

  •  "type": "rust" specifies that example_backend is a rust type canister.

  •  "candid": "src/example_backend/example_backend.did"" specifies the location of the Candid interface description file to use for the canister.

  •  "package": "example_backend" specifies the package name of the Rust crate. It should be the same as in the crate Cargo.toml file.

Cargo.toml

In the root directory, there is a Cargo.toml file. It defines a Rust workspace by specifying paths to each Rust crate. A Rust canister is just a Rust crate compiled to WebAssembly. Here you have one member at src/example_backend which is the only Rust canister.

Cargo.toml
[workspace]
members = [
    "src/example_backend",
]

Backend canister Cargo.toml file

Now you are in the Rust canister. As with any standard Rust crate, it has a Cargo.toml file that configures the details to build the Rust crate.

src/example_backend/Cargo.toml
[package]
name = "example_backend"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
crate-type = ["cdylib"]

[dependencies]
candid = "0.8.2"
ic-cdk = "0.7.0"
serde = { version = "1.0", features = ["derive"] }

Notice the crate-type = ["cdylib"] line, which is necessary to compile this Rust program into the WebAssembly module.

Default canister code

The default project has a simple greet function that uses the Rust CDK query macro.

src/example_backend/src/lib.rs
#[ic_cdk::query]
fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}

Candid file

Candid is an interface description language (IDL) for interacting with canisters running on ICP. Candid files provide a language-independent description of a canister’s interfaces, including the names, parameters, and result formats and data types for each function a canister defines.

By adding Candid files to your project, you can ensure that data is properly converted from its definition in Rust to run safely on ICP.

To see details about the Candid interface description language syntax, see the Candid Guide or the Candid crate documentation.

src/example_backend/example_backend.did
service : {
    "greet": (text) -> (text) query;
}

This definition specifies that the greet function is a query method that takes text data as input and returns text data.

Declaring global variables

Static variables are global variables that the protocol preserves across upgrades. For example, a user database should probably be static.

Flexible variables are global variables that the protocol discards on code upgrade. For example, it is reasonable to make a cache flexible if keeping this cache hot is not critical for your product.

For example:

thread_local! {
    static USERS: RefCell<Users> = ... ;
}

Canister interfaces

In comparison to Motoko, where the compiler automatically generates the corresponding Candid file, a different approach is recommended for Rust development.

Making the .did file the canister's source of truth

Your Candid file should be the main source of documentation for people who want to interact with your canister, including your colleagues who work on the front end portion. The interface should be stable, easy to find, and well documented, which is not something you can get automatically.

The following is an example of a .did file:

type TransferError = variant {
  // The debit account didn't have enough funds
  // for completing the transaction.
  InsufficientFunds : Balance;
  // ...
};
type TransferResult =
  variant { Ok : BlockHeight; Err : TransferError; };
service {
  // Transfer funds between accounts.
  transfer : (TransferArgs) -> (TransferResult);
}

For more information on Candid and to see the Rust equivalent of Candid types, please see the Candid reference documentation.

To make sure that your .did file and your implementation are in sync with one another, use the Candid tooling. There are macros in the Rust CDK that allow you to annotate your Canister methods and extract the .did file.

The latest versions of the Candid package have functions to check that one interface is a subtype of another interface, which is a Candid term for “backward compatible.”

Using variant types to indicate error cases

Rust error types tend to make it easy to recover from errors correctly for API consumers, while Candid variants can help clients handle edge case errors more gracefully. Using variant types is also the preferred method of error handling in Motoko.

The following is an example of using variant types to indicate error cases:

type CreateEntityResult = variant {
  Ok  : record { entity_id : EntityId; };
  Err : opt variant {
    EntityAlreadyExists;
    NoSpaceLeftInThisShard;
  }
};
service : {
  create_entity : (EntityParams) -> (CreateEntityResult);
}

If a service method returns a result type, it can still reject the call. Therefore, there may not be much benefit from adding error variants like InvalidArgument or Unauthorized, as there is no meaningful way to recover from such errors programmatically. So rejecting malformed, invalid, or unauthorized requests is probably the right thing to do in most situations.