Introduction to Cargo

In this lesson, we will dive into the Rust Cargo tool, which helps manage projects, dependencies, and builds. You'll learn how to create new projects, compile code, and run tests efficiently using Cargo.

Cargo is a command-line tool that ships with the standard installation of Rust. You can verify its installation with $ cargo -V command. It should print its version like below.

$ cargo -V
cargo 1.81.0 (2dbb1af80 2024-08-20)

So the question is, what can Cargo do? Let’s go through its capabilities one at a time.

1. Project creation

Cargo can manage a project for us. For Cargo to work efficiently and intelligently, it needs a Cargo.toml file at the root of the project directory. This file contains important project information such as the project’s name, version, a list of dependencies the project relies on, and other configuration settings. In the Rust universe, a project with “Cargo.toml” is called a crate.

Instead of creating the Cargo.toml file ourselves, we can ask Cargo to create a brand new project (crate) for us. We do this by using the cargo new command:

$ cargo new hello_world

This command creates a hello_world directory in the location where it was run and places a Cargo.toml file inside it. The file looks like the following:

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

[dependencies]

In the [package] section, it defines crate-related information such as the name of the crate, its version, and the Rust edition it uses. The [dependencies] section is where Cargo manages the crate’s dependencies. It’s empty at the moment since our crate doesn’t depend on any external crates yet.

When choosing a project or crate name, we should be mindful of some conventions. The name should only contain lowercase letters, with optional underscores (_) and numbers. It’s idiomatic in Rust to use the snake_case convention, such as my_project or create_100. If you plan to publish your crate on crates.io, make sure that the name is also unique on the registry.

Along with Cargo.toml, Cargo also creates .gitignore file. This happens because when we use cargo new command, Cargo also initializes a Git repository in the directory. If we don’t want Cargo to initialize a git repository, we can use the --vcs flag with none value.

$ cargo new hello_world --vcs none

If you want to initialize a different VCS (Version Control System) repository instead of git, which is the default for the --vcs flag (when you don’t provide it), you can specify another option, such as hg, fossil, etc. You can also configure Cargo to always use none or another VCS value by default when creating or initializing a new project. This can be done using the global Cargo configuration file, which is located at ~/.cargo/config.toml on Unix systems. The file might look like the following:

[cargo-new]
vcs = "none"

You can refer to this documentation for more information about Cargo configuration and its file location on your device.

If you would like to provide a different crate name than the directory name, you can use the --name flag, like this: $ cargo new hello_world --name hello_crate. This will create a hello_world/ directory with a Cargo.toml file inside it, but the crate name will be set to hello_crate.

If you already have a directory and want to initialize a Cargo project inside it, you can do so by running the $ cargo init command within that directory. This command will generate the same Cargo.toml file, using the directory name as the crate name unless you provide the --name flag. If the directory name contains invalid characters, such as uppercase letters or hyphens, Cargo will automatically remove them from the crate name. You can also use the --vcs and --name flags with cargo init, just like with the cargo new command.

Additionally, you will find a src directory inside the crate. This is where the source code (.rs files) of the crate will reside. If you look inside this directory, you’ll find a main.rs file with some boilerplate code that Cargo has generated for us. The main.rs file is the entry point of our program. It contains the following code:

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

This looks exactly the same as our Hello World program from the previous lesson. As we learned in that lesson, the main function is a special function in Rust as it’s the first function that Rust executes. Similarly, main.rs is a special file in Rust. It is the file that Rust looks for in the crate to execute the main function.

If you use the --lib flag with the $ cargo new or $ cargo init command, it will generate a lib.rs file inside the src directory, since we are creating a non-executable library crate. We will explore more about this in upcoming lessons.

2. Runing project code

Now comes the question, how do we run this program? If you remember from the previous lesson, we used the $ rustc command to compile our Rust program into machine code and then executed the binary file. With Cargo, it’s even simpler. Cargo provides the run subcommand to run our crate, and it automatically takes care of locating the main.rs file (which by default is inside src/).

$ cargo run

   Compiling hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.37s
     Running `target/debug/hello_world`
Hello, world!

When we run the $ cargo run command, Cargo starts compiling the hello_world crate using the rustc command. In the output, it displays the name and version of the crate, as well as the location of the project on the disk. In the second line of the output, it shows that the compilation was done using the development profile, which compiles code faster but produces unoptimized code and includes debug information such as line numbers and variable names to make debugging easier. The compiled binary file will be stored inside the target/debug directory (relative to the crate directory). We’ll discuss the target directory in the next section.

After that, Cargo shows that it’s running the compiled binary located at target/debug/hello_world. Just like how we executed a compiled Rust binary file in the previous lesson, $ cargo run does the same. It outputs the Hello, world! text since that’s what the main function is doing.

If you would like even more information about what Cargo is doing behind the scenes, you can use the --verbose flag with the $ cargo run command. On the other hand, if you want to suppress all of that information and only see the program output, you can use the --quiet flag.

3. Build System

As we learned, Cargo utilizes Rust’s compiler rustc to compile Rust code, but its responsibilities extend to managing the builds. The $ cargo run command we used earlier actually performs two tasks. First, it runs the $ cargo build command, which compiles the code and produces the binary file. The second task is to execute this binary file (which outputs the Hello, world! text).

Let’s remove the target/ directory from our project and run the $ cargo build command.

$ rm -rf ./target
$ cargo build
   Compiling hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.98s

The $ cargo build command compiles the crate and generates a binary executable file inside target/debug. If you look inside this directory, you will find the hello_world file (or hello_world.exe if you are on Windows), since our crate is named hello_world. You can now run this file manually.

$ ./target/debug/hello_world
Hello, world!

Now, let’s build it one more time using the same cargo build command.

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s

Huh! Notice that it doesn’t output the Compiling hello_world v0.1.0... message anymore. What’s happening? Cargo uses incremental compilation, meaning it only recompiles the parts of your code that have changed, instead of rebuilding the entire project every time you make a small modification. This is useful during development because it speeds up builds, although it can sometimes result in a slightly less optimized binary. These incremental build artifacts are stored in the target/debug/incremental directory.

Cargo also employs a fingerprinting system to decide whether or not to recompile a crate, storing these fingerprints in the target/debug/.fingerprint directory. It computes the fingerprint based on the source file contents, dependencies, build configurations (such as build flags, profiles, etc.), and compiler settings (such as optimization level, debug info, etc.). If none of these elements have changed, Cargo skips recompilation. That’s exactly what’s happening here. On subsequent $ cargo build commands, Cargo doesn’t recompile the crate because there are no changes to the source code, dependencies (which we don’t have yet), or build configurations.

Cargo also compiles and caches dependencies inside the target/debug/deps directory. This ensures that Cargo doesn’t need to re-download or recompile dependencies unless the project requires a different version than what’s cached.

Hence the executable binary file at /target/debug/hello_world never recompiles. The reason Cargo places the file inside the debug directory is because the default build profile used with the command $ cargo build is dev. A build profile in Rust is a set of predefined compilation settings in Cargo that control optimization levels, debug information, and other compiler behaviors to suit different stages of development or production. Cargo comes with dev and release profiles built in.

Cargo also provides a test profile, which inherits settings from the dev profile and is used specifically for running tests.

For dev profile, the optimization level is 0 (opt-level = 0) which basically means no binary optimization, it generates debug information (debug = true) and uses incremental builds (incremental = true). On the other hand, the release profile has highest optimization level (opt-level = 3), it doesn’t output debug information (debug = false) and doesn’t use incremental builds (incremental = false).

If you want to produce builds for release, such as when distributing your software or running it in a production environment, you should use the release profile. To use the release profile, simply add the --release flag to the $ cargo build or $ cargo run command.

$ rm -rf ./target
$ cargo build --release
   Compiling hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
    Finished `release` profile [optimized] target(s) in 0.30s

With the release profile, Cargo outputs the compiled binary file at /target/release/hello_world, along with other files inside the /target/release directory.

$ ./target/release/hello_world
Hello, world!

If you would like to modify the configuration of the dev or release profile, you can do so by using the [profile.dev] or [profile.release] section in the Cargo.toml file, as shown below:

[profile.release]
incremental = true

With this change, we configure the release profile to use incremental builds. This will make release builds faster since the entire crate doesn’t need to be recompiled. However, this comes at the cost of generating a less optimized binary, which may not run as fast.

Apart from the opt-level, debug, and incremental settings, there are many more fields in a Cargo profile that can be configured. You can view the full list here. Instead of modifying an existing profile, you can also define a custom profile in the Cargo.toml file.

[profile.devopt]
inherits = "dev"
opt-level = 3

In this example, we inherit all settings from the dev profile and override the opt-level to produce an optimized binary during development. To use this custom profile, you need to pass the --profile devopt flag with the $ cargo build or $ cargo run command.

$ cargo build --profile devopt
   Compiling hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
    Finished `devopt` profile [optimized + debuginfo] target(s) in 6.30s

This will create an optimized binary, as indicated in the output logs, but injecting the debug information since the new profile inherits from the dev profile, which has debug set to true. The binary will be placed inside the target/devopt directory.

$ ./target/devopt/hello_world
Hello, world!

By default, Cargo uses the target directory to store build artifacts, but you can override this behavior by specifying a custom directory with the --target-dir flag when using the $ cargo build or $ cargo run command.

4. Package Manager

When working on a real-world project, we want to focus on writing code that addresses the business logic specific to our project requirements. For low-level tasks such as creating/managing network traffic, handling disk operations, or image processing — or even for high-level tasks like date and time manipulation — we can rely on open-source or proprietary software libraries.

Like NPM for Node.js or Maven for Java, Cargo uses the crates.io registry to host Rust crates. Anyone can upload or download crates from this registry using cargo commands. But before we move on to installing a crate from crates.io, let’s first understand how to import a crate into our program and use it.

// main.rs
use std::println;

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

In the main.rs we still gonna use our good old Hello World program but we have a new statement at the top of the file. The use std::println; statement imports the println! macro from std crate. The std crate (also called Rust standard library) comes with standard Rust installation, so we don’t need to install it manually using Cargo.

The use keyword in Rust brings something from a package into scope. A scope could be a file or a module (we will discuss modules in a separate lesson). We use the :: notation to access elements inside a package. For example, here we are accessing the println! macro from the std crate. The use std::println statement brings the println! macro into scope, allowing us to use println!() directly. If you prefer not to bring println! into scope, you could also use std::println!("Hello, world!");, but the former is simpler.

But hang on, earlier we didn’t have to write the use std::println; statement at the top of the file. So what’s going on?

The answer is the prelude. A prelude is a collection of commonly used functions, macros, types, and traits from the std crate that are automatically brought into scope by Rust, so we don’t need to explicitly use the use statement for them. The println! macro is part of the prelude, which is why we didn’t have to bring it into scope manually in earlier examples.

Let’s do something interesting that is not part of the prelude.

// main.rs
use std::fs;

fn main() {
    let message: String = fs::read_to_string("message.txt").unwrap();
    println!("Message from file: {message}");
}

In the program above, we are bringing the fs module into scope. A module in Rust is a way to group related code (such as functions) into a namespace to organize functionality and avoid naming conflicts. The fs module contains functions that let us read or write files on disk easily. Here, we are using the read_to_string function from the fs module to read a file’s content from the disk as a human-readable string.

If you want to bring everything from a crate into scope, you can use the use std::* statement. However, this is considered unsafe because it doesn’t clearly show what has been brought into scope. Using many such statements can lead to situations where naming conflicts occur, potentially causing bugs in your code.

The read_to_string function returns a Result enum (which can contain either the file content as a string or an error), which is why we call the unwrap function to ignore the error and get the string content. We store this content inside the message variable, which we define using the let keyword. Then, we print this content to the screen using the println! macro. The {message} part in the println! macro substitutes the content of the message variable.

If you’re confused about the :: and . notation, don’t worry! We’ll cover these in upcoming lessons. For now, just know that when a crate or module exposes something, we use the :: notation to access it. The . is used to access a field or method, like calling unwrap() method on the Result object.

Now, let’s use a crate that doesn’t come with Rust’s standard installation. Working with dates in Rust can be quite difficult since the Rust standard library (std) doesn’t provide direct support for formatting or handling human-readable dates. One crate that makes this easier is chrono. You can visit its documentation on crates.io. The current version, as of writing this lesson, is 0.4.38.

There are two ways we can download this crate using Cargo. The first way is to manually add chrono = "0.4.38" under the [dependencies] section of the Cargo.toml file:

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

[dependencies]
chrono = "0.4.38"

Rust provides great IDE support. As soon as we add this entry to Cargo.toml, it automatically downloads and installs the crate.

The second way is to use the cargo add <crate-name> command, which allows us to install a crate directly from the command line without manually modifying Cargo.toml. For example:

$ cargo add chrono
    Updating crates.io index
      Adding chrono v0.4.38 to dependencies

After the successful installation, this command also modifies the Cargo.toml file to include the entry chrono = "0.4.38" under [dependencies].

Cargo generates a Cargo.lock file to track the exact versions of dependencies and indirect dependencies (dependencies of dependencies) to ensure that builds are reproducible. You should always check in this file to your version control system (VCS), so that Cargo can use it to download the exact versions of dependencies on your team members’ devices, CI/CD platforms, or production environments.

You don’t need to run a separate command to install dependencies. Cargo automatically downloads and installs the dependencies listed in Cargo.toml with the help of Cargo.lock when you run $ cargo run or $ cargo build.

Now that the chrono crate is downloaded and ready to be used, let’s write a simple program to make use of it. We will create a program to print the current date in a human-readable format. Achieving this with Rust’s standard library can be quite difficult, so let’s see how chrono can help us out.

// main.rs
use chrono::Local;

fn main() {
    // get the current local date and time
    let now = Local::now();

    // format the date as "Monday January 1 2024"
    let formatted_date = now.format("%A %B %-d %Y");
    println!("{}", formatted_date);
}

To get the current local datetime using chrono, we need to call the Local::now() associated function provided by the Local struct (we will talk about structs in a separate lesson). Since Rust doesn’t know where Local is located, we need to bring it into scope using the use chrono::Local statement (we could also use chrono::Local::now() without bringing it into scope). The Local::now() expression returns a chrono::DateTime struct, which we then store in the now variable. The DateTime struct has a format method that takes a string template and returns a human-readable date accordingly.

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/hello_world`
Friday September 27 2024

5. Dependency Resolution

Cargo follows the Semantic Versioning (SemVer) strategy to download and manage dependencies. When you use the cargo add command or manually add dependencies in Cargo.toml, Cargo retrieves those dependencies from crates.io and installs them according to the version constraints you specify, along with any indirect dependencies that those crates require. Cargo keeps track of the exact versions installed in the Cargo.lock file to ensure reproducible builds. You can use the $ cargo tree command to visualize the dependency graph of your project.

$ cargo tree

hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
└── chrono v0.4.38
    ├── iana-time-zone v0.1.61
    │   └── core-foundation-sys v0.8.7
    └── num-traits v0.2.19
        [build-dependencies]
        └── autocfg v1.4.0

Let’s see how Cargo resolves dependencies with an example. Suppose our crate depends on a hypothetical crate, crate_x = "0.7.1", and every possible version of crate_x exists on the registry. In this case, Cargo will download version 0.7.9 (< 0.8.0), as that is the highest compatible version it can download according to SemVer rules, ensuring no breaking changes are introduced.

crate_x = "0.7.1" is equivalent to crate_x = "^0.7.1" in Cargo. According to the ^ notation, Cargo will safely download the maximum compatible patch version within the 0.7.x range, such as 0.7.9, without introducing a breaking change. For 0.x.x versions (pre-1.0), only patch updates are considered safe, and minor version updates (like 0.8.0) are considered breaking changes. If it was crate_x = "1.7.1", it would be equivalent to crate_x = "^1.7.1", and Cargo would download the maximum compatible minor version, such as 1.9.9, because for major versions >=1.0.0, SemVer allows updates to minor and patch versions as long as the major version remains the same, ensuring no breaking changes.

Now let’s say that our crate depends on crate_a and crate_b, and they internally depend on crate_x = "0.8.1" and crate_x = "0.8.2", respectively. Cargo will attempt to use the highest compatible version of crate_x for both crate_a and crate_b, which could be 0.8.9. However, if our crate directly depends on crate_x = "0.7.9", this would create a conflict because Cargo cannot resolve both 0.7.x and 0.8.x versions simultaneously. In this case, Cargo would give us an error due to the version incompatibility.

error: failed to select a version for `crate_x`.
failed to select a version for `crate_x` which could resolve this conflict

The recommended solution is to update dependencies to the latest or an appropriate version. You can also rename a dependency in Cargo.toml so that two versions of the same crate can act as different crates, but this should be avoided unless absolutely necessary. You can read more about this here.

6. Publishing Crates

Cargo also allows us to publish crates to crates.io. Since we are working on a project created by Cargo, which is itself a crate, publishing it to crates.io is as simple as running $ cargo login and $ cargo publish. However, the important part isn’t the publishing process, but rather how we structure the project, what we expose, and how we write the documentation. Towards the end of our Rust learning journey, we will work on a couple of crates and publish them on crates.io.

7. Documentation Generation

Cargo can also generate documentation for our projects using documentation comments, or doc comments (///). Cargo supports Markdown, which means we can use Markdown syntax within our documentation comments. Let’s modify our earlier example and add some doc comments.

// main.rs
use chrono::{DateTime, Local};

/// Returns a `chrono::DateTime<Local>` in a human-readable text format.
/// The format used for printing is: `Weekday Month Day Year` (e.g., "Friday September 27 2024").
///
/// # Arguments
/// * `date` - A `chrono::DateTime<Local>` value representing the date and time to be printed.
///
/// # Returns
/// * A formatted string representing the date and time.
///
/// # Examples
/// ```
/// use chrono::{DateTime, Local};
///
/// let date = Local::now();
/// let formatted_date = format_datetime(date);
/// ```
///
fn format_datetime(date: DateTime<Local>) -> String {
    let formatted_date = date.format("%A %B %-d %Y");

    return formatted_date.to_string();
}

fn main() {
    let now = Local::now();
    let formatted = format_datetime(now);
    println!("{formatted}");
}

Here, we have extracted the date formatting logic into its own function format_datetime. This function takes an argument date of type DateTime<Local> and returns a human-readable text so that we can print it to STDOUT using the println! macro we are familiar with. Logically, nothing major has changed, but we added some doc comments to the format_datetime function.

By using ///, we instruct Cargo to treat the comments as documentation that will be included when generating docs for the project. First, we provide a summary of what the function does. Then, in the # Arguments section, we describe the parameters of the function. Optionally, we can add a # Returns section to explain what the function returns (if applicable). We also give an example of how to use the function in the # Examples section. Since Cargo documentation supports Markdown, we are free to use it wherever necessary, though there is a general convention you can follow, as described in the Rust documentation guide.

This documentation is also utilized by IDEs to provide in-line information about what a function (or other items) are supposed to do, offering clear insights without needing to navigate to the official documentation of the crate. Since I’m using VSCode, here’s how the format_datetime function appears in the IDE:

You can generate a static website containing your crate’s documentation using Cargo. To create this website, simply run the $ cargo doc command, which will generate the documentation and store it in the target/doc directory. For convenience, you can also run $ cargo doc --open, which both generates the documentation and automatically opens it in your browser for preview.

This functionality is also leveraged by docs.rs to display comprehensive documentation for crates, all generated from the doc comments within the code. The generated documentation typically looks like the example below:

8. Testing

Cargo provides out-of-the-box testing support. You don’t need to install any test runner or assertion libraries like you do in many other languages. Cargo includes the $ cargo test command, which scans for tests in your source code or dedicated test files and runs them. Let’s write a simple test suite to unit test our format_datetime function.

// main.rs

// --snip--

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{Local, TimeZone};

    #[test]
    fn test_format_datetime() {
        // create a known fixed date
        let date = Local.with_ymd_and_hms(2024, 9, 26, 0, 0, 0).unwrap();

        // format the date using `format_datetime`
        let formatted_date = format_datetime(date);

        // verify the formatted date is as expected
        assert_eq!(formatted_date, "Friday September 27 2024");
    }
}

Let’s understand what’s happening here one step at a time:

  • To write tests, we need to define a module that will house those tests. Here, we defined a module tests using the mod tests statement. The name tests isn’t mandatory. We can give it any name we want, but tests is more idiomatic in Rust for test modules.
  • The #[cfg(test)] attribute configures the tests module to only be compiled and included when testing. Whenever we run the $ cargo test command, Cargo knows to look for test code inside modules annotated with #[cfg(test)]. Additionally, this attribute ensures that the code inside the tests module is excluded from the final binary, as it’s not needed at runtime.
  • Inside this module, we define functions that contain the test logic. While the function name typically begins with test_ by convention, it’s not required as long as the function is annotated with the #[test] attribute. You can also define utility functions inside this module without the #[test] attribute, and they won’t be executed as part of the test suite.
  • To bring items from the parent module into the tests module scope, we use the use super::* statement, where super refers to the parent module. This allows us to use the format_datetime function from the parent module (which is the root module in this case) within the tests module.
  • We also need to explicitly import external dependencies, such as chrono::{Local, TimeZone}, into the tests module using the use statement. This is because the parent module doesn’t automatically export them into the tests module. We will cover this behavior in more detail in a dedicated lesson on modules and visibility.
  • The assert_eq! macro is part of the Rust prelude, meaning it’s available by default in every scope, so there’s no need to import it manually. This macro takes two arguments to compare and panics if they do not match. A panic is an unrecoverable error that indicates a failed test. If a panic occurs, the test fails, and Cargo will show us the details of the failure.
$ cargo test
   Compiling hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running unittests src/main.rs (target/debug/deps/hello_world-c60ff28f5b32f02b)

running 1 test
test tests::test_format_datetime ... ok

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

When we run the $cargo test command, as we can see from the logs, it picks up the tests from the src/main.rs file, specifically tests::test_format_datetime, which is a test function. Thankfully, our test passed, meaning the format_datetime function is behaving exactly as expected.

9. Prepare for Git

Cargo supports Git out of the box, as we have learned so far. If you use the $ cargo new or $ cargo init command, it will automatically initialize a Git repository in the crate directory, unless instructed otherwise. It also creates a .gitignore file that looks like this:

/target

We obviously do not want to track build and cache files within a repository. For library crates, it’s advised to ignore the Cargo.lock file, but for binary crates, it must be tracked to ensure reproducible builds. Other than this, there are no special instructions for handling Rust projects.

10. Configure your IDE

Rust has excellent support for VSCode through the community-maintained extension rust-analyzer, developed by rust-lang.org. Here are the key benefits of using this extension:

  • It suggests Rust code snippets and methods as you type, helping you write code faster and with fewer errors.
  • It highlights syntax errors, warnings, and potential issues in real-time, improving code quality as you work.
  • It allows quick navigation to the definition of functions, structs, or variables with a simple click.
  • It automatically formats your Rust code according to standard conventions using rustfmt, keeping your code clean and consistent.

For a Rust beginner, this is a great tool to have in your IDE.

#rust #introduction