Chapter 1. Truth or Consequences
And the truth is, we don’t know anything
They Might Be Giants, “Ana Ng” (1988)
In this chapter, I’ll show you how to organize, run, and test a Rust program. I’ll be using a Unix platform (macOS) to explain some basic ideas about command-line programs. Only some of these ideas apply to the Windows operating system, but the Rust programs themselves will work the same no matter which platform you use.
You will learn how to do the following:
-
Compile Rust code into an executable
-
Use Cargo to start a new project
-
Use the
$PATH
environment variable -
Include external Rust crates from crates.io
-
Interpret the exit status of a program
-
Use common system commands and options
-
Write Rust versions of the
true
andfalse
programs -
Organize, write, and run tests
Getting Started with “Hello, world!”
It seems the universally agreed-upon way to start learning a programming language is printing “Hello, world!” to the screen.
Change to a temporary directory with cd /tmp
to write this first program.
We’re just messing around, so we don’t need a real
directory yet.
Then fire up a text editor and type the following code into a file called hello.rs:
fn
main
(
)
{
println!
(
"
Hello, world!
"
)
;
}
Functions are defined using
fn
. The name of this function ismain
.println!
(print line) is a macro and will print text toSTDOUT
(pronounced standard out). The semicolon indicates the end of the statement.
Rust will automatically start in the main
function.
Function arguments appear inside the parentheses that follow the name of the function.
Because there are no arguments listed in main()
, the function takes no arguments.
The last thing I’ll point out here is that println!
looks like a function but is actually a macro, which is essentially code that writes code.
All the other macros I use in this book—such as assert!
and vec!
—also end with an exclamation point.
To run this program, you must first use the Rust compiler, rustc
, to compile the code into a form that your computer can execute:
$ rustc hello.rs
On Windows, you will use this command:
> rustc.exe .\hello.rs
If all goes well, there will be no output from the preceding command, but you should now have a new file called hello on macOS and Linux or hello.exe on Windows.
This is a binary-encoded file that can be directly executed by your operating system, so it’s common to call this an executable or a binary.
On macOS, you can use the file
command to see what kind of file this is:
$ file hello hello: Mach-O 64-bit executable x86_64
You should be able to execute the program to see a charming and heartfelt message:
$ ./hello Hello, world!
Tip
I will shortly discuss the $PATH
environment variable that lists the directories to search for programs to run. The current working directory is never included in this variable to prevent malicious code from being surreptitiously executed. For instance, a bad actor could create a program named ls
that executes rm -rf /
in an attempt to delete your entire filesystem. If you happened to execute that as the root user, it would ruin your whole day.
On Windows, you can execute it like so:
> .\hello.exe Hello, world!
Congratulations if that was your first Rust program. Next, I’ll show you how to better organize your code.
Organizing a Rust Project Directory
In your Rust projects, you will likely write many files of source code and will also use other people’s code from places like crates.io.
It’s best to create a directory for each project, with a src subdirectory for the Rust source code files.
On a Unix system, you’ll first need to remove the hello binary with the command rm hello
because that is the name of the directory you will create.
Then you can use the following command to make the directory structure:
$ mkdir -p hello/src
The
mkdir
command will make a directory. The-p
option says to create parent directories before creating child directories. PowerShell does not require this option.
Move the hello.rs source file into hello/src using the mv
command:
$ mv hello.rs hello/src
Use the cd
command to change into that directory and compile your program again:
$ cd hello $ rustc src/hello.rs
You should now have a hello
executable in the directory.
I will use the tree
command (which you might need to install) to show you the contents of my directory:
$ tree . ├── hello └── src └── hello.rs
This is the basic structure for a simple Rust project.
Creating and Running a Project with Cargo
An easier way to start a new Rust project is to use the Cargo tool. You can delete your temporary hello directory:
$ cd .. $ rm -rf hello
Change into the parent directory, which is indicated with two dots (
..
).The
-r
recursive option will remove the contents of a directory, and the-f
force option will skip any errors.
If you would like to save the following program, change into the solutions directory for your projects. Then start your project anew using Cargo like so:
$ cargo new hello Created binary (application) `hello` package
This should create a new hello directory that you can change into.
I’ll use tree
again to show you the contents:
$ cd hello $ tree . ├── Cargo.toml └── src └── main.rs
Cargo.toml is a configuration file for the project. The extension .toml stands for Tom’s Obvious, Minimal Language.
The src directory is for Rust source code files.
main.rs is the default starting point for Rust programs.
You can use the following cat
command (for concatenate) to see the contents of the one source file that Cargo created (in Chapter 3, you will write a Rust version of cat
):
$ cat src/main.rs fn main() { println!("Hello, world!"); }
Rather than using rustc
to compile the program, this time use cargo run
to compile the source code and run it in one command:
$ cargo run Compiling hello v0.1.0 (/private/tmp/hello) Finished dev [unoptimized + debuginfo] target(s) in 1.26s Running `target/debug/hello` Hello, world!
The first three lines are information about what Cargo is doing.
This is the output from the program.
If you would like for Cargo to not print status messages about compiling and running the code, you can use the -q
, or --quiet
, option:
$ cargo run --quiet Hello, world!
After running the program using Cargo, use the ls
command to list the contents of the current working directory.
(You will write a Rust version of ls
in Chapter 14.)
There should be a new directory called target.
By default, Cargo will build a debug target, so you will see the directory target/debug that contains the build artifacts:
$ ls Cargo.lock Cargo.toml src/ target/
You can use the tree
command from earlier or the find
command (you will write a Rust version of find
in Chapter 7) to look at all the files that Cargo and Rust created.
The executable file that ran should exist as target/debug/hello.
You can execute this directly:
$ ./target/debug/hello Hello, world!
To summarize, Cargo found the source code in src/main.rs, used the main
function there to build the binary target/debug/hello, and then ran it.
Why was the binary file called hello, though, and not main?
To answer that, look at Cargo.toml:
$ cat Cargo.toml [package] name = "hello" version = "0.1.0" edition = "2021" # See more keys and their definitions at # https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies]
This was the name of the project I created with Cargo, so it will also be the name of the executable.
This is the version of the program.
This is the edition of Rust that should be used to compile the program. Editions are how the Rust community introduces changes that are not backward compatible. I will use the 2021 edition for all the programs in this book.
This is a comment line that I will include only this one time. You can remove this line from your file, if you like.
This is where you will list any external crates your project uses. This project has none at this point, so this section is blank.
Note
Rust libraries are called crates, and they are expected to use semantic version numbers in the form major.minor.patch
, so that 1.2.4
is major version 1, minor version 2, patch version 4. A change in the major version indicates a breaking change in the crate’s public application programming interface (API).
Writing and Running Integration Tests
“More than the act of testing, the act of designing tests is one of the best bug preventers known. The thinking that must be done to create a useful test can discover and eliminate bugs before they are coded—indeed, test-design thinking can discover and eliminate bugs at every stage in the creation of software, from conception to specification, to design, coding, and the rest.”
Boris Beizer, Software Testing Techniques (Van Nostrand Reinhold)
Even though “Hello, world!” is quite simple, there are still things that could bear testing.
There are two broad categories of tests I will show in this book.
Inside-out or unit testing is when you write tests for the functions inside your program.
I’ll introduce unit testing in Chapter 5.
Outside-in or integration testing is when you write tests that run your programs as the user might, and that’s what we’ll do for this program.
The convention in Rust projects is to create a tests directory parallel to the src directory for testing code, and you can use the command mkdir tests
for this.
The goal is to test the hello
program by running it on the command line as the user will do.
Create the file tests/cli.rs for command-line interface (CLI) with the following code.
Note that this function is meant to show the simplest possible test in Rust, but it doesn’t do anything useful yet:
#[
test
]
fn
works
(
)
{
assert!
(
true
)
;
}
The
#[test]
attribute tells Rust to run this function when testing.The
assert!
macro asserts that a Boolean expression istrue
.
Your project should now look like this:
$ tree -L 2 . ├── Cargo.lock ├── Cargo.toml ├── src │ └── main.rs ├── target │ ├── CACHEDIR.TAG │ ├── debug │ └── tmp └── tests └── cli.rs
The Cargo.lock file records the exact versions of the dependencies used to build your program. You should not edit this file.
The src directory is for the Rust source code files to build the program.
The target directory holds the build artifacts.
The tests directory holds the Rust source code for testing the program.
All the tests in this book will use assert!
to verify that some expectation is true
, or assert_eq!
to verify that something is an expected value.
Since this test evaluates the literal value true
, it will always succeed.
To see this test in action, execute cargo test
.
You should see these lines among the output:
running 1 test test works ... ok
To observe a failing test, change true
to false
in the tests/cli.rs file:
#[
test
]
fn
works
(
)
{
assert!
(
false
)
;
}
Among the output, you should see the following failed test:
running 1 test test works ... FAILED
Tip
You can have as many assert!
and assert_eq!
calls in a test function as you like. At the first failure of one of them, the whole test fails.
Now, let’s create a more useful test that executes a command and checks the result.
The ls
command works on both Unix and Windows PowerShell, so we’ll start with that.
Replace the contents of tests/cli.rs with the following code:
use
std
::
process
::
Command
;
#[
test
]
fn
runs
(
)
{
let
mut
cmd
=
Command
::
new
(
"
ls
"
)
;
let
res
=
cmd
.
output
(
)
;
assert!
(
res
.
is_ok
(
)
)
;
}
Import
std::process::Command
. Thestd
tells us this is in the standard library and is Rust code that is so universally useful it is included with the language.Create a new
Command
to runls
. Thelet
keyword will bind a value to a variable. Themut
keyword will make this variable mutable so that it can change.Run the command and capture the output, which will be a
Result
.Verify that the result is an
Ok
variant, indicating the action succeeded.
Run cargo test
and verify that you see a passing test among all the output:
running 1 test test runs ... ok
Update tests/cli.rs with the following code so that the runs
function executes hello
instead of ls
:
use
std
::
process
::
Command
;
#[
test
]
fn
runs
(
)
{
let
mut
cmd
=
Command
::
new
(
"
hello
"
)
;
let
res
=
cmd
.
output
(
)
;
assert!
(
res
.
is_ok
(
)
)
;
}
Run the test again and note that it fails because the hello
program can’t be found:
running 1 test test runs ... FAILED
Recall that the binary exists in target/debug/hello.
If you try to execute hello
on the command line, you will see that the program can’t be found:
$ hello -bash: hello: command not found
When you execute any command, your operating system will look in a predefined set of directories for something by that name.1
On Unix-type systems, you can inspect the PATH
environment variable of your shell to see this list of directories, which are delimited by colons.
(On Windows, this is $env:Path
.)
I can use tr
(translate characters) to replace the colons (:
) with newlines (\n
) to show you my PATH
:
$ echo $PATH | tr : '\n' /opt/homebrew/bin /Users/kyclark/.cargo/bin /Users/kyclark/.local/bin /usr/local/bin /usr/bin /bin /usr/sbin /sbin
Even if I change into the target/debug directory, hello
still can’t be found due to the aforementioned security restrictions that exclude the current working directory from my PATH
:
$ cd target/debug/ $ hello -bash: hello: command not found
I must explicitly reference the current working directory for the program to run:
$ ./hello Hello, world!
Next, I need to find a way to execute binaries that exist only in the current crate.
Adding Project Dependencies
Currently, the hello
program exists only in the target/debug directory.
If I copy it to any of the directories in my PATH
(note that I include the $HOME/.local/bin directory for private programs), I can execute it and run the test successfully.
But I don’t want to copy my program to test it; rather, I want to test the program that lives in the current crate.
I can use the crate assert_cmd
to find the program in my crate directory.
I will also add the crate pretty_assertions
to use a version of the assert_eq!
macro that shows differences between two strings better than the default version.
I first need to add these as development dependencies to Cargo.toml. This tells Cargo that I need these crates only for testing and benchmarking:
[
package
]
name
=
"
hello
"
version
=
"
0.1.0
"
edition
=
"
2021
"
[
dependencies
]
[
dev-dependencies
]
assert_cmd
=
"
2.0.13
"
pretty_assertions
=
"
1.4.0
"
I can then use assert_cmd
to create a Command
that looks in the Cargo binary directories.
The following test does not verify that the program produces the correct output, only that it appears to succeed.
Update your tests/cli.rs with the following code so that the runs
function will use assert_cmd::Command
instead of std::process::Command
:
use
assert_cmd
::
Command
;
#[
test
]
fn
runs
(
)
{
let
mut
cmd
=
Command
::
cargo_bin
(
"
hello
"
)
.
unwrap
(
)
;
cmd
.
assert
(
)
.
success
(
)
;
}
Import
assert_cmd::Command
.Create a
Command
to runhello
in the current crate. This returns aResult
, and the code callsResult::unwrap
because the binary should be found. If it isn’t, thenunwrap
will cause a panic and the test will fail, which is a good thing.Use
Assert::success
to ensure the command succeeded.
Note
I’ll have more to say about the Result
type in later chapters. For now, just know that this is a way to model something that could succeed or fail for which there are two possible variants, Ok
and Err
, respectively.
Run cargo test
again and verify that you now see a passing test:
running 1 test test runs ... ok
Understanding Program Exit Values
What does it mean for a program to run successfully?
Command-line programs should report a final exit status to the operating system to indicate success or failure.
The Portable Operating System Interface (POSIX) standards dictate that the standard exit code is 0 to indicate success (think zero errors) and any number from 1 to 255 otherwise.
I can show you this using the bash
shell and the true
command.
Here is the manual page from man true
for the version that exists on macOS:
TRUE(1) BSD General Commands Manual TRUE(1) NAME true -- Return true value. SYNOPSIS true DESCRIPTION The true utility always returns with exit code zero. SEE ALSO csh(1), sh(1), false(1) STANDARDS The true utility conforms to IEEE Std 1003.2-1992 (''POSIX.2''). BSD June 27, 1991 BSD
As the documentation notes, this program does nothing except return the exit code zero.
If I run true
, it produces no output, but I can inspect the bash
variable $?
to see the exit status of the most recent command:
$ true $ echo $? 0
The false
command is a corollary in that it always exits with a nonzero exit code:
$ false $ echo $? 1
All the programs you will write in this book will be expected to return zero when they terminate normally and a nonzero value when there is an error.
You can write versions of true
and false
to see this.
Start by creating a src/bin directory using mkdir src/bin
, then create src/bin/true.rs with the following contents:
fn
main
(
)
{
std
::
process
::
exit
(
0
)
;
}
Use the
std::process::exit
function to exit the program with the value zero.
Your src directory should now have the following structure:
$ tree src/ src/ ├── bin │ └── true.rs └── main.rs
Run the program and manually check the exit value:
$ cargo run --quiet --bin true $ echo $? 0
Add the following test to tests/cli.rs to ensure it works correctly.
It does not matter if you add this before or after the existing runs
function:
#[test]
fn
true_ok
()
{
let
mut
cmd
=
Command
::cargo_bin
(
"true"
).
unwrap
();
cmd
.
assert
().
success
();
}
If you run cargo test
, you should see the results of the two tests:
running 2 tests test true_ok ... ok test runs ... ok
Note
The tests are not necessarily run in the same order they are declared in the code. This is because Rust is a safe language for writing concurrent code, which means code can be run across multiple threads. The testing takes advantage of this concurrency to run many tests in parallel, so the test results may appear in a different order each time you run them. This is a feature, not a bug. If you would like to run the tests in order, you can run them on a single thread via cargo test -- --test-threads=1
.
Rust programs will exit with the value zero by default.
Recall that src/main.rs doesn’t explicitly call std::process::exit
.
This means that the true
program can do nothing at all.
Want to be sure?
Change src/bin/true.rs to the following:
fn
main
()
{}
Run the test suite and verify it still passes.
Next, let’s write a version of the false
program with the following source code in src/bin/false.rs:
fn
main
(
)
{
std
::
process
::
exit
(
1
)
;
}
Manually verify that the exit value of the program is not zero:
$ cargo run --quiet --bin false $ echo $? 1
Then add this test to tests/cli.rs to verify that the program reports a failure when run:
#[
test
]
fn
false_not_ok
(
)
{
let
mut
cmd
=
Command
::
cargo_bin
(
"
false
"
)
.
unwrap
(
)
;
cmd
.
assert
(
)
.
failure
(
)
;
}
Use the
Assert::failure
function to ensure the command failed.
Run cargo test
to verify that the programs all work as expected:
running 3 tests test runs ... ok test true_ok ... ok test false_not_ok ... ok
Another way to write the false
program uses std::process::abort
. Change src/bin/false.rs to the following:
fn
main
()
{
std
::process
::abort
();
}
Again, run the test suite to ensure that the program still works as expected.
Testing the Program Output
While it’s nice to know that my hello
program exits correctly, I’d like to ensure it actually prints the correct output to STDOUT
, which is the standard place for output to appear and is usually the console.
Update your runs
function in tests/cli.rs to the
following:
use
assert_cmd
::
Command
;
use
pretty_assertions
::
assert_eq
;
#[
test
]
fn
runs
(
)
{
let
mut
cmd
=
Command
::
cargo_bin
(
"
hello
"
)
.
unwrap
(
)
;
let
output
=
cmd
.
output
(
)
.
expect
(
"
fail
"
)
;
assert!
(
output
.
status
.
success
(
)
)
;
let
stdout
=
String
::
from_utf8
(
output
.
stdout
)
.
expect
(
"
invalid UTF-8
"
)
;
assert_eq!
(
stdout
,
"
Hello, world!
\n
"
)
;
}
Import the
pretty_assertions::assert_eq
macro for comparing values instead of the standard Rust version.Call
Command::output
to execute thehello
command. UseResult::expect
to get the output of the command or die with the message “fail.”Convert the output of the program to UTF-8, which I’ll discuss more in Chapter 4.
Compare the output from the program to an expected value. Note that this will use the
pretty_assertions
version of theassert_eq
macro.
Run the tests and verify that hello
does, indeed, work correctly.
Next, change src/main.rs to add some more exclamation points:
fn
main
()
{
println!
(
"Hello, world!!!"
);
}
Run the tests again to observe a failing test:
running 3 tests test true_ok ... ok test false_not_ok ... ok test runs ... FAILED failures: ---- runs stdout ---- thread runs panicked at tests/cli.rs:10:5: assertion failed: `(left == right)` Diff < left / right > : <Hello, world!!! >Hello, world!
The preceding test result is trying very hard to show you how the expected output (the “right”) differs from the actual output (the “left”). The terminal output even includes red and green text and highlighted text that cannot be reproduced here. While this is a trivial program, I hope you can see the value in automatically checking all aspects of the programs we write.
Exit Values Make Programs Composable
Correctly reporting the exit status is a characteristic of well-behaved command-line programs.
The exit value is important because a failed process used in conjunction with another process should cause the combination to fail.
For instance, I can use the logical and operator &&
in bash
to chain the two commands true
and ls
.
Only if the first process reports success will the second process run:
$ true && ls Cargo.lock Cargo.toml src/ target/ tests/
If instead I execute false && ls
, the result is that the first process fails and ls
never runs.
Additionally, the exit status of the whole command is nonzero:
$ false && ls $ echo $? 1
Ensuring that command-line programs correctly report errors makes them composable with other programs. It’s extremely common in Unix environments to combine many small commands to make ad hoc programs on the command line. If a program encounters an error but fails to report it to the operating system, then the results could be incorrect. It’s far better for a program to abort so that the underlying problems can be fixed.
Summary
This chapter introduced you to some key ideas about organizing a Rust project and some basic ideas about command-line programs. To recap:
-
The Rust compiler
rustc
compiles Rust source code into a machine-executable file on Windows, macOS, and Linux. -
The Cargo tool will create a new Rust project as well as compile, run, and test the code.
-
By default,
cargo new
creates a new Rust program that prints “Hello, world!” -
Command-line tools like
ls
,cd
,mkdir
, andrm
often accept command-line arguments like file or directory names as well as options like-f
or-p
. -
POSIX-compatible programs should exit with a value of 0 to indicate success and any value between 1 and 255 to indicate an error.
-
You learned to add crate dependencies to Cargo.toml and use the crates in your code.
-
You created a tests directory to organize testing code, and you used
#[test]
to mark functions that should be executed as tests. -
You learned how to test a program’s exit status as well as how to check the text printed to
STDOUT
. -
You learned how to write, run, and test alternate binaries in a Cargo project by creating source code files in the src/bin directory.
-
You wrote your implementations of the
true
andfalse
programs along with tests to verify that they succeed and fail as expected. You saw that by default a Rust program will exit with the value zero and that thestd::process::exit
function can be used to explicitly exit with a given code. Additionally, thestd::process::abort
function can be used to exit with a nonzero error code.
In the next chapter, I’ll show you how to write a program that uses command-line arguments to alter the output.
1 Shell aliases and functions can also be executed like commands, but I’m only talking about finding programs to run at this point.
Get Command-Line Rust now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.