Rust (2024 edition) Module 1 – Getting started: rustup, Cargo and compiler messages
Your first Cargo project: build, run and test
Create a package with cargo new, check, build and run it, compare the dev and release profiles, pass arguments, read Cargo.lock and run tests.
What you will learn
- Create, check, build and run a Cargo package
- Explain the dev and release profiles, the target directory and Cargo.lock
- Pass arguments to a program through cargo run --
- Run a package's tests and read cargo test's summary and exit status
Before you start
What you’ll learn · Before you start
What you will learn
- Create, check, build and run a Cargo package
- Explain the dev and release profiles, the target directory and Cargo.lock
- Pass arguments to a program through cargo run --
- Run a package's tests and read cargo test's summary and exit status
Before you start
On this page
The same 8-line program printed seats booked: 4 when Cargo built it one way, and stopped with “attempt to add with
overflow” when Cargo built it another way. Both runs were right: Cargo builds every package: A folder with a Cargo.toml at its top: one or more crates, a program, a library or both, together with the description Cargo needs to build, test and share them. in a
profile: A named set of compiler settings that Cargo builds with. dev is the default for cargo build and cargo run; release, chosen with --release, optimises and turns overflow checks off., and the two default profiles check different things.
This lesson starts a package with cargo new and follows it through cargo check, cargo build and cargo run, then
explains the dev and release profiles, the target folder and Cargo.lock, how arguments reach your program, and how
cargo test reports a failing test, all on recorded runs of Rust 1.99.0.
cargo new writes a package you can run at once
A package is a folder with a Cargo.toml at the top: the code, and a description of how to
build it. cargo new hello writes one, and this session lists what it wrote and runs it:
from session import run
# A new package, the files cargo new wrote, and its first run.
run("""
cargo new hello
find hello -not -path '*/.git/*' -not -name .git | sort
cat hello/Cargo.toml
cat hello/src/main.rs
cat hello/.gitignore
cd hello
cargo run
""") Output
$ cargo new hello
Creating binary (application) `hello` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ find hello -not -path '*/.git/*' -not -name .git | sort
hello
hello/.gitignore
hello/Cargo.toml
hello/src
hello/src/main.rs
$ cat hello/Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2024"
[dependencies]
$ cat hello/src/main.rs
fn main() {
println!("Hello, world!");
}
$ cat hello/.gitignore
/target
$ cd hello
$ cargo run
Compiling hello v0.1.0 (~/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in …
Running `target/debug/hello`
Hello, world!
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 new_session.py
Three files appeared, in hello and its src folder, beside a hidden .git folder that the listing leaves out. The
manifest: A package's Cargo.toml: its name, version and edition in the [package] table, the other packages it depends on, and settings such as profiles., Cargo.toml, names the package and its version and says
which edition it is written in: edition = "2024", the newest, which cargo new chooses by default. Its empty
[dependencies] table is where other packages will be listed. src/main.rs is the program, and the name matters:
Cargo builds src/main.rs into a program and src/lib.rs into a library, the file cargo new --lib writes instead.
The .gitignore holds one line, /target, because cargo new also makes the folder a git repository, unless it is
already inside one.
cargo run compiled the package, said which profile it used, and ran the program. The time it reports changes on every
run, so these pages show it as …; on your computer it is a number of seconds.
Checkpoint
Check: What cargo new writes
cargo check, build and run stop at different steps
Building a program takes several steps, and the three commands stop at different ones. Step through what each command of the next session does:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- cargo check. cargo check reads Cargo.toml and checks the code, the step where most compiler errors are found, then stops. It writes only data for later checks, so target/debug gets no program.
- cargo build. cargo build goes on: it generates machine code and links it into target/debug/hello, a program of its own. It prints Compiling instead of Checking.
- The program by itself. ./target/debug/hello runs the built program directly. Cargo is not involved at all: the file is an ordinary executable.
- cargo run, up to date. cargo run builds first if the sources changed. Here nothing had, so it printed only Finished and ran the program at once.
Here is the same package, new again, checked, built and run, with the contents of target/debug listed after the check
and after the build:
from session import run
# The same new package, checked, then built, then run: watch what appears in target/debug.
run("""
cargo new hello
cd hello
cargo check
ls target/debug
cargo build
ls target/debug
./target/debug/hello
cargo run
""") Output
$ cargo new hello
Creating binary (application) `hello` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cd hello
$ cargo check
Checking hello v0.1.0 (~/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in …
$ ls target/debug
build
deps
examples
incremental
$ cargo build
Compiling hello v0.1.0 (~/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in …
$ ls target/debug
build
deps
examples
hello
hello.d
incremental
$ ./target/debug/hello
Hello, world!
$ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in …
Running `target/debug/hello`
Hello, world!
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_session.py
After cargo check, target/debug held only Cargo’s working folders; after cargo build it held hello, a program
that ./target/debug/hello ran without Cargo. The Cargo book describes cargo check as compiling “without performing
the final step of code generation”, which is why it is faster, and the reason it is the command to run again and again
while you write code. The same page adds a caveat: a few errors only appear during code generation, so run
cargo build before you are done. cargo run builds whatever has changed and then runs the program; the second time,
nothing had changed, and it only printed Finished.
Checkpoint
Check: cargo check, build and run
Two profiles: dev for working, release for shipping
Every build uses a profile, a named set of compiler settings. cargo build, cargo run and
cargo check use dev, and --release selects release. These are the defaults of the Cargo reference:
| Setting | dev | release |
|---|---|---|
opt-level, how hard to optimise |
0 | 3 |
debug, information for debuggers |
yes | no |
debug-assertions and overflow-checks |
on | off |
incremental, reuse earlier work |
yes | no |
Timing a program built with cargo run
cargo run builds with dev, at opt-level 0. A program timed that way can be much slower than the one users get,
so measure a build made with --release, and say which profile you measured.
The difference in checks is not only about speed. This program books ten more seats in a u8, which holds 0 to 255:
fn main() {
// A u8 holds 0 to 255; ten more bookings take 250 past the top.
let mut booked: u8 = 250;
for _ in 0..10 {
booked += 1;
}
println!("seats booked: {booked}");
} Its Cargo.toml also defines a profile of its own, release-checked: everything of release, plus the overflow checks
of dev. The session runs it in all three profiles:
[package]
name = "seats"
version = "0.1.0"
edition = "2024"
[dependencies]
# A profile of your own: everything of release, plus the overflow checks of dev.
[profile.release-checked]
inherits = "release"
overflow-checks = true from session import run
# One program in three profiles, then what the three builds left in the package folder.
run("""
cd seats
!cargo run
cargo run --release
!cargo run --profile release-checked
ls target
cat Cargo.lock
""") Output
$ cd seats
$ cargo run
Compiling seats v0.1.0 (~/seats)
Finished `dev` profile [unoptimized + debuginfo] target(s) in …
Running `target/debug/seats`
thread 'main' (…) panicked at src/main.rs:5:9:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[exit status 101]
$ cargo run --release
Compiling seats v0.1.0 (~/seats)
Finished `release` profile [optimized] target(s) in …
Running `target/release/seats`
seats booked: 4
$ cargo run --profile release-checked
Compiling seats v0.1.0 (~/seats)
Finished `release-checked` profile [optimized] target(s) in …
Running `target/release-checked/seats`
thread 'main' (…) panicked at src/main.rs:5:9:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[exit status 101]
$ ls target
CACHEDIR.TAG
debug
release
release-checked
$ cat Cargo.lock
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "seats"
version = "0.1.0"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 profiles_session.py
In dev the addition panicked, “attempt to add with overflow”, exit status 101. In release it wrapped silently to 4.
In release-checked, optimised like release but with overflow-checks = true, it panicked again. Each profile wrote
its own folder in target, as the Cargo book’s page on the build cache lists them: debug for dev, release for
release, and release-checked, named after the custom profile.
Key idea
A profile is a set of compiler settings: dev for working, with every check on, and release for shipping, optimised and without overflow checks. Code that relies on a check must turn it on in the profile it ships with.
Checkpoint
Check: The dev and release profiles
Cargo.lock records the exact versions, and target holds the builds
The last command of the previous session printed Cargo.lock, which Cargo wrote beside Cargo.toml at the first
build. It lists every package in the build with its exact version; seats has no dependencies, so it lists only
itself. Once a package has dependencies, Cargo.toml says which versions are acceptable, such as any compatible
release of a library, and Cargo.lock records the exact ones that were chosen, so the next build on any computer uses
the same. Step through how the package folder grows:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- cargo new. cargo new hello writes the folder, the manifest Cargo.toml, src/main.rs and a .gitignore, and makes the folder a git repository.
- The first build. The first build writes Cargo.lock beside Cargo.toml and puts everything it compiles in target/debug, the folder of the dev profile.
- A release build. cargo build --release, or cargo run --release, compiles again with the release profile and keeps its output apart, in target/release.
- A profile of your own. A custom profile such as release-checked gets a folder of its own name, target/release-checked, so no build overwrites another.
- What git keeps. The .gitignore holds one line, /target, so the builds stay out of version control. Cargo.lock is not ignored: by default cargo new tracks it in version control.
Two rules follow from this. target holds only what can be rebuilt, so the .gitignore of cargo new keeps it out
of version control, and you can delete it whenever you need the space back. Cargo.lock is different: by default
cargo new tracks it in version control, and the Cargo FAQ says that whether you commit it depends on the needs of
your package, with one limit to keep in mind. It gives your own builds the same versions every time, but as the FAQ
puts it, “Cargo.lock does not affect the consumers of your package, only Cargo.toml does that”: a library’s users
get the versions their own build chooses.
Checkpoint
Check: Cargo.lock and the target folder
Arguments for your program come after a double dash
A program reads its command-line arguments with std::env::args(), whose first item is the program’s own path. This
one greets every name it is given and exits with status 2 when there is none:
use std::env;
use std::process::ExitCode;
fn main() -> ExitCode {
// The first argument is the program's own path; the names come after it.
let names: Vec<String> = env::args().skip(1).collect();
if names.is_empty() {
eprintln!("usage: namaste NAME...");
return ExitCode::from(2);
}
for name in &names {
println!("Namaste, {name}!");
}
ExitCode::SUCCESS
} cargo run takes options of its own, so it needs a way to tell them apart from the program’s. The rule of the Cargo
book: everything after -- goes to the program, everything before it to Cargo:
from session import run
# Arguments after -- go to the program; arguments before it go to Cargo.
run("""
cd namaste
cargo run -- Asha Ravi
cargo run -- --release
cargo run --release -- Asha
!cargo run
""") Output
$ cd namaste
$ cargo run -- Asha Ravi
Compiling namaste v0.1.0 (~/namaste)
Finished `dev` profile [unoptimized + debuginfo] target(s) in …
Running `target/debug/namaste Asha Ravi`
Namaste, Asha!
Namaste, Ravi!
$ cargo run -- --release
Finished `dev` profile [unoptimized + debuginfo] target(s) in …
Running `target/debug/namaste --release`
Namaste, --release!
$ cargo run --release -- Asha
Compiling namaste v0.1.0 (~/namaste)
Finished `release` profile [optimized] target(s) in …
Running `target/release/namaste Asha`
Namaste, Asha!
$ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in …
Running `target/debug/namaste`
usage: namaste NAME...
[exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 args_session.py
cargo run -- --release built in dev and greeted someone called --release, because the program received it.
cargo run --release -- Asha gave --release to Cargo and Asha to the program. And with no names, the program’s exit
status 2 came straight back through cargo run, which is what a script that runs your program will see.
An option on the wrong side of --
cargo run -- --release passes --release to your program and builds in dev. Options for Cargo, such as
--release, go before --; options and arguments for your program go after it.
Checkpoint
Check: Arguments after --
cargo test runs every test and fails when one fails
Tests are functions marked #[test]. Unit tests sit beside the code, in a module marked #[cfg(test)] that is compiled
only for tests; integration test: A test in a file of a package's tests folder. Each file is compiled as a separate program that uses the library from outside, as any other crate would. are files in a tests folder that use the library
the way any other A unit of Rust code that the compiler builds as a whole: a library or a program. A Cargo package holds one or more crates. would. This library has one function and three tests, one of which describes a rule the function
does not follow yet:
/// A greeting for one person, by name.
pub fn greet(name: &str) -> String {
format!("Namaste, {name}!")
}
#[cfg(test)]
mod tests {
use super::greet;
#[test]
fn greets_by_name() {
assert_eq!(greet("Asha"), "Namaste, Asha!");
}
#[test]
fn ignores_spaces_around_the_name() {
assert_eq!(greet(" Ravi "), "Namaste, Ravi!");
}
} use greeting::greet;
#[test]
fn greets_two_people() {
assert_eq!(greet("Meera"), "Namaste, Meera!");
assert_eq!(greet("Kabir"), "Namaste, Kabir!");
} The session runs every test, with --test-threads=1 after --, so that the tests run one at a time and print in the
same order on every run, and then the integration test file sample on its own:
from session import run
# All the package's tests, one at a time so the order stays the same, then one integration test file on its own.
run("""
cd greeting
!cargo test -- --test-threads=1
cargo test --test sample
""") Output
$ cd greeting
$ cargo test -- --test-threads=1
Compiling greeting v0.1.0 (~/greeting)
Finished `test` profile [unoptimized + debuginfo] target(s) in …
Running unittests src/lib.rs (target/debug/deps/greeting-…)
running 2 tests
test tests::greets_by_name ... ok
test tests::ignores_spaces_around_the_name ... FAILED
failures:
---- tests::ignores_spaces_around_the_name stdout ----
thread 'tests::ignores_spaces_around_the_name' (…) panicked at src/lib.rs:17:9:
assertion `left == right` failed
left: "Namaste, Ravi !"
right: "Namaste, Ravi!"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::ignores_spaces_around_the_name
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in …
error: test failed, to rerun pass `--lib`
[exit status 101]
$ cargo test --test sample
Finished `test` profile [unoptimized + debuginfo] target(s) in …
Running tests/sample.rs (target/debug/deps/sample-…)
running 1 test
test greets_two_people ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in …
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_session.py
The Finished lines name test, the profile that cargo test builds with. Read the failure from the bottom up. The
summary says 1 passed; 1 failed, and the command exited with status 101,
which is how a script or CI notices. Above it, the assertion shows both values: greet kept the spaces around Ravi.
cargo test stopped after the first test program that failed, so the integration tests never ran in that command;
--no-fail-fast runs them all anyway. cargo test --test sample ran just that file, and it passed.
Checkpoint
Check: Running and reading cargo test
Practise: make the failing test pass
Change greet in your own copy of the package so that ignores_spaces_around_the_name passes, without breaking the
other two tests. Then decide what greet(" ") should return, write a test that says so, and make it pass too. When
your tests pass, compare with one way to do it:
/// A greeting for one person, by name. Spaces around the name are ignored,
/// and a name that is only spaces gives a greeting without a name.
pub fn greet(name: &str) -> String {
let name = name.trim();
if name.is_empty() {
return String::from("Namaste!");
}
format!("Namaste, {name}!")
}
#[cfg(test)]
mod tests {
use super::greet;
#[test]
fn greets_by_name() {
assert_eq!(greet("Asha"), "Namaste, Asha!");
}
#[test]
fn ignores_spaces_around_the_name() {
assert_eq!(greet(" Ravi "), "Namaste, Ravi!");
}
#[test]
fn greets_without_a_name() {
assert_eq!(greet(" "), "Namaste!");
}
} from session import run
# One way to make every test pass: trim the name, and greet without a name when nothing is left.
run("""
cd greeting-solved
cargo test -- --test-threads=1
""") Output
$ cd greeting-solved
$ cargo test -- --test-threads=1
Compiling greeting v0.1.0 (~/greeting-solved)
Finished `test` profile [unoptimized + debuginfo] target(s) in …
Running unittests src/lib.rs (target/debug/deps/greeting-…)
running 3 tests
test tests::greets_by_name ... ok
test tests::greets_without_a_name ... ok
test tests::ignores_spaces_around_the_name ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in …
Running tests/sample.rs (target/debug/deps/sample-…)
running 1 test
test greets_two_people ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in …
Doc-tests greeting
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in …
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 solved_session.py
trim() removes the spaces at both ends, and an empty name after trimming gets a greeting of its own. This time all
three test programs ran: the unit tests, the integration test and the documentation tests, of which there are none yet.
Finally, the questions on packages, profiles, Cargo.lock and tests. Each choice carries its own reason, and the ideas
you miss are asked again before the ones you know:
Practice that adapts to you
Practise this lesson
14 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is the difference between cargo check, cargo build and cargo run?
All three read Cargo.toml and compile the package, and they stop at different points. cargo check checks the code,
syntax, types and borrows, and skips code generation, so it is the fastest way to see compiler errors and produces no
program. cargo build goes on to generate machine code and link the program, into target/debug with the dev profile,
or target/release with --release. cargo run builds whatever has changed and then runs the program, passing it the
arguments after --. A good answer adds two details: a few errors appear only during code generation, so cargo check
alone is not a full build, and cargo run uses the dev profile unless you pass --release, which matters for timing and
for overflow checks.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A colleague rewrote a log-parsing script in Rust, timed it with cargo run on a 2 GB file, and found it slower than the Python script. What do you check, and in what order, before anyone draws a conclusion?
-
Mid to senior
Your library's CI is green, with Cargo.lock committed, but a user reports that a new project depending on your library no longer builds: a new release of one of your dependencies broke it. Explain how both can be true, and what you change.
Key takeaways
cargo newwrites a package:Cargo.tomlwith the 2024 edition,src/main.rs(orsrc/lib.rswith--lib), a.gitignorefor/targetand a git repository.cargo checkstops after checking the code and is the fast loop;cargo buildalso generates code and links a program intotarget/debug;cargo runbuilds what changed and runs it.- The dev profile keeps overflow checks and debug information; release optimises and wraps silently on overflow, and a
custom profile with
inheritscan combine the two. Cargo.lockrecords the exact version of every package in the build and is tracked in version control by default, whiletargetholds only rebuildable output.- Arguments after
--go to your program and options before it to Cargo, andcargo testexits with 101 when a test fails, after showing both values of the failed assertion.
Terms in this lesson
- integration test
- A test in a file of a package's tests folder. Each file is compiled as a separate program that uses the library from outside, as any other crate would.
- manifest
- A package's Cargo.toml: its name, version and edition in the [package] table, the other packages it depends on, and settings such as profiles.
- package
- A folder with a Cargo.toml at its top: one or more crates, a program, a library or both, together with the description Cargo needs to build, test and share them.
- profile
- A named set of compiler settings that Cargo builds with. dev is the default for cargo build and cargo run; release, chosen with --release, optimises and turns overflow checks off.
References
- The Cargo Book: First Steps with Cargo (The Rust Project)
- The Cargo Book: Package Layout (The Rust Project)
- The Cargo Book: cargo new (The Rust Project)
- The Cargo Book: cargo check (The Rust Project)
- The Cargo Book: cargo run (The Rust Project)
- The Cargo Book: cargo test (The Rust Project)
- The Cargo Book: Profiles (The Rust Project)
- The Cargo Book: Build cache (The Rust Project)
- The Cargo Book: Cargo.toml vs Cargo.lock (The Rust Project)
- The Cargo Book: Frequently Asked Questions (The Rust Project)
- The Rust Programming Language: Hello, Cargo! (The Rust Project)
- The Rust Programming Language: Test Organization (The Rust Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress