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 asmy_project
orcreate_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 alib.rs
file inside thesrc
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 thedev
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 callingunwrap()
method on theResult
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 tocrate_x = "^0.7.1"
in Cargo. According to the^
notation, Cargo will safely download the maximum compatible patch version within the0.7.x
range, such as0.7.9
, without introducing a breaking change. For0.x.x
versions (pre-1.0), only patch updates are considered safe, and minor version updates (like0.8.0
) are considered breaking changes. If it wascrate_x = "1.7.1"
, it would be equivalent tocrate_x = "^1.7.1"
, and Cargo would download the maximum compatible minor version, such as1.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 themod tests
statement. The nametests
isn’t mandatory. We can give it any name we want, buttests
is more idiomatic in Rust for test modules. - The
#[cfg(test)]
attribute configures thetests
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 thetests
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 theuse super::*
statement, wheresuper
refers to the parent module. This allows us to use theformat_datetime
function from the parent module (which is the root module in this case) within thetests
module. - We also need to explicitly import external dependencies, such as
chrono::{Local, TimeZone}
, into thetests
module using theuse
statement. This is because the parent module doesn’t automatically export them into thetests
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. Apanic
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.