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 an external Rust crate from crates.io
Interpret the exit status of a program
Use common system commands and options
Write Rust versions of the true
and false
programs
Organize, write, and run tests
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 is main
.
println!
(print line) is a macro and will print text to STDOUT
(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:
$ ./helloHello, world!
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.
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.
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.
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).
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 4.
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 is true
.
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 is evaluating 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 result:
running 1 test test works ... FAILED
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
. The std
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 run ls
. The let
keyword will bind a value to a variable. The mut
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.
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.
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 first need to add this as a development dependency to Cargo.toml.
This tells Cargo that I need this crate only for testing and benchmarking:
[package]
name
=
"hello"
version
=
"0.1.0"
edition
=
"2021"
[dependencies]
[dev-dependencies]
assert_cmd
=
"1"
I can then use this crate 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 run hello
in the current crate. This returns a Result
, and the code calls Result::unwrap
because the binary should be found. If it isn’t, then unwrap
will cause a panic and the test will fail, which is a good thing.
Use Assert::success
to ensure the command succeeded.
I’ll have more to say about the Result
type in following 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
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
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.
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:
#[
test
]
fn
runs
(
)
{
let
mut
cmd
=
Command
::
cargo_bin
(
"
hello
"
)
.
unwrap
(
)
;
cmd
.
assert
(
)
.
success
(
)
.
stdout
(
"
Hello, world!
\n
"
)
;
}
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 'Unexpected stdout, failed diff var original ├── original: Hello, world! ├── diff: --- value expected +++ value actual @@ -1 +1 @@ -Hello, world!+Hello, world!!!
└── var as str: Hello, world!!! command=`".../hello/target/debug/hello"`
code=0
stdout=```"Hello, world!!!\n"```
stderr=```""```
This is the expected output from the program.
This is the output the program actually created.
This is a shortened version of the command that was run by the test.
The exit code from the program was 0
.
This is the text that was received on STDOUT
.
This is the text that was received on STDERR
(pronounced standard error), which I will discuss in the next chapter.
Learning to read test output is a skill in itself and takes practice. The preceding test result is trying very hard to show you how the expected output differs from the actual output. While this is a trivial program, I hope you can see the value in automatically checking all aspects of the programs we write.
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.
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 helps create a new Rust project and also compiles, runs, and tests the code.
Command-line tools like ls
, cd
, mkdir
, and rm
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.
By default, cargo new
creates a new Rust program that prints “Hello, world!”
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 own implementations of the true
and false
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 the std::process::exit
function can be used to explicitly exit with a given code. Additionally, the std::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.