Hello everyone; after receiving a lot of interest in a recent tweet, I’m starting this blog to share my experience with the Rust programming language. I figured I might as well start with the absolute basics—getting Rust installed and writing and building your first project. If you haven’t already read it, I encourage you to start with the Rust Book. Everything I will share in this post is available there, so skim it, refer to it, or read it thoroughly. I hope what I write here can also be helpful, as I know a different approach and set of examples can sometimes be beneficial in understanding a topic.

In this tutorial, we go over the following:

  • Setting up your IDE
  • Installing Rust
  • Getting started with Cargo
  • Compiling your program for production

If you have any feedback or prefer we go over anything different, please let me know on Twitter at @nbrempel!

Setting Up Your IDE

There are many code editors, and I’m sure many of these tools are productive environments to work in. However, if you’re unsure what to use, I recommend Visual Studio Code and the Rust Analyzer extension. I use this as it has many helpful tools—especially when understanding Rust’s ownership concept.

Installing Rust

The first step to working in Rust is installing it on your machine. The best way to do this is to use rustup, an easy-to-use installer. Install the tool on your machine using the following command:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Follow the instructions in this prompt to install everything you need to start working in Rust. Once it’s installed, you can easily update to the latest version of Rust when it’s available by running rustup update.

Working with Cargo

Cargo is Rust’s build tool and package manager. It can manage and publish crates (which are what Rust’s external libraries and dependencies are called), create new Rust components, and compile your project.

Starting a new project

Using Cargo to create a new Rust project is straightforward. The following command will create a new project called hello-world:

> cargo new hello-world
Created binary (application) `hello-world` package

If you already have an existing directory you want to you, you can use cargo init:

> mkdir existing-directory
> cd existing-directory/
> cargo init --name hello-world

Created binary (application) package

At this point, you will have a very simple hello world project:

.
├── Cargo.toml
└── src
    └── main.rs
[package]
name = "hello-world"
version = "0.1.0"
edition = "2021"

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

[dependencies]

Cargo.toml

fn main() {
    println!("Hello, world!");
}

main.rs

This is the most basic Hello World application package, which simply prints out the string “Hello, world! “To run this program, you can use Cargo to build and execute the binary:

> cargo run
Compiling hello-world v0.1.0 (/Users/nremp/Projects/existing-directory)
    Finished dev [unoptimized + debuginfo] target(s) in 0.64s
     Running `target/debug/hello-world`
Hello, world!

You’ve now officially run your first Rust program!

Cargo will also look for entry points in the /src/bin directory. For example, you can add a file called /src/bin/hello-bin.rs and execute this program like so:

cargo run --bin hello-bin

lib vs. bin

In the previous example, we created a new Rust project with the command cargo new hello-world. This command created a new binary package which, when compiled, produces an executable file that is intended to be run. In fact, we could have passed the —-bin flag to be more explicit:

cargo new hello-world --bin

But what if we want to create a library designed to be imported by other projects instead? We can pass the —-lib flag to create a library package in this case.

> cargo new my-lib --lib
Created library `my-lib` package

In this case, Cargo produces a different directory structure:

.
├── Cargo.toml
└── src
    └── lib.rs
pub fn add(left: usize, right: usize) -> usize {
    left + right
}

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

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

lib.rs

Notice how the example that is produced includes a unit test. Since we can’t run this library directly, we can include a test alongside our code to see how it works. You can use the command cargo test to run the test. Pro-tip: if you add any print statements to your tests, you won’t see the output unless you use the —-nocapture flag! You can read more about Rust’s test framework here.

> cargo test -- --nocapture
    Finished test [unoptimized + debuginfo] target(s) in 0.01s
     Running unittests src/lib.rs (target/debug/deps/my_lib-899502c70b06808d)

running 1 test
test result: 4
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests my-lib

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Putting it all together

A typical pattern when writing a Rust application is to start with a single module and split your work into multiple files only once the file length becomes unwieldy or when you want to share logic between numerous entry points. Let’s look at a simple example. Say we have the following main.rs:

// A simple program that takes a list of numbers
// as an input and prints the sum of the numbers.
fn main() {
    let integers = std::env::args()
        .skip(1)
        .map(|x| x.parse::<i32>().unwrap())
        .collect::<Vec<i32>>();
    let sum = sum(&integers);

    println!("Sum of {integers:?} is {sum}");

    // Do other stuff...
}

fn sum(integers: &[i32]) -> i32 {
    integers.iter().fold(0, |sum, x| sum + x)
}

Running this program produces the following output:

> cargo run 1 2 3 4 5
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/hello-world 1 2 3 4 5`
Sum of [1, 2, 3, 4, 5] is 15

This is clearly a contrived example. But if for some reason, you wanted to call sum from another module, you wouldn’t yet be able to. To do this, sum will need to be moved to lib.rs. What we end up with is something like the following:

.
├── Cargo.toml
└── src
    ├── bin
    │   └── sum.rs
    └── lib.rs
pub fn sum(integers: &[i32]) -> i32 {
    integers.iter().fold(0, |sum, x| sum + x)
}

lib.rs

use hello_world::sum;

fn main() {
    let integers = std::env::args()
        .skip(1)
        .map(|x| x.parse::<i32>().unwrap())
        .collect::<Vec<i32>>();
    let sum = sum(&integers);

    println!("Sum of {integers:?} is {sum}");

    // Do other stuff...
}

sum.rs

We can use the use keyword to import the sum function. Make sure you use the pub keyword in the library so that it can be imported from other modules!

Workspaces

If your project grows large enough, you may want to consider breaking the project down even further into more components in what’s called a workspace. A workspace is a pattern used by Cargo that allows you to isolate related code into local crates in your project directory. Each crate has its own Cargo.toml file, so you can control the size of the binary produced by the compiler. Workspaces also help with logical separation when dealing with many files.

Compiling Your Program

When you execute cargo run, you will notice a new target directory shows up in your project. This is because Cargo implicitly compiles your project in debug mode before running the output. If you wanted to, you could run the result directly. In our previous example, we had a sum.rs file. This is compiled into a sum binary for your machine’s architecture:

> ./target/debug/sum 1 2 3 4 5
Sum of [1, 2, 3, 4, 5] is 15

The default dev release profile compiles much faster but actually runs much more slowly. When you want to deploy your work into some production environment, you will want to use the release profile to make your program 10-100x faster.

> cargo build --release
   Compiling hello-world v0.1.0 (/Users/nremp/Projects/hello-world)
    Finished release [optimized] target(s) in 0.76s

> ./target/release/sum 1 2 3 4 5
Sum of [1, 2, 3, 4, 5] is 15

These release profiles are configurable, but dev and release are some sane built-in defaults.

That wraps up my quick primer on how to get started writing Rust code. I hope this guide is helpful. If you have any questions, feedback, or requests for what I should write about, please let me know on Twitter at @nbrempel. If you found this article helpful, please consider sharing it with someone you think would find it useful. The more people read this, the more motivated I will be to keep writing!

Getting Started With Rust and Cargo

A straightforward primer for working in Rust