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 and false 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() { 1
    println!("Hello, world!"); 2
} 3
1

Functions are defined using fn. The name of this function is main.

2

println! (print line) is a macro and will print text to STDOUT (pronounced standard out). The semicolon indicates the end of the statement.

3

The body of the function is enclosed in curly braces.

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 1
Hello, world!
1

The dot (.) indicates the current directory.

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 1
1

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 .. 1
$ rm -rf hello 2
1

Change into the parent directory, which is indicated with two dots (..).

2

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 1
└── src 2
    └── main.rs 3
1

Cargo.toml is a configuration file for the project. The extension .toml stands for Tom’s Obvious, Minimal Language.

2

The src directory is for Rust source code files.

3

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) 1
    Finished dev [unoptimized + debuginfo] target(s) in 1.26s
     Running `target/debug/hello`
Hello, world! 2
1

The first three lines are information about what Cargo is doing.

2

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" 1
version = "0.1.0" 2
edition = "2021" 3

# See more keys and their definitions at 4
# https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies] 5
1

This was the name of the project I created with Cargo, so it will also be the name of the executable.

2

This is the version of the program.

3

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.

4

This is a comment line that I will include only this one time. You can remove this line from your file, if you like.

5

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] 1
fn works() {
    assert!(true); 2
}
1

The #[test] attribute tells Rust to run this function when testing.

2

The assert! macro asserts that a Boolean expression is true.

Your project should now look like this:

$ tree -L 2
.
├── Cargo.lock 1
├── Cargo.toml
├── src 2
│   └── main.rs
├── target 3
│   ├── CACHEDIR.TAG
│   ├── debug
│   └── tmp
└── tests 4
    └── cli.rs
1

The Cargo.lock file records the exact versions of the dependencies used to build your program. You should not edit this file.

2

The src directory is for the Rust source code files to build the program.

3

The target directory holds the build artifacts.

4

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; 1

#[test]
fn runs() {
    let mut cmd = Command::new("ls"); 2
    let res = cmd.output(); 3
    assert!(res.is_ok()); 4
}
1

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.

2

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.

3

Run the command and capture the output, which will be a Result.

4

Verify that the result is an Ok variant, indicating the action succeeded.

Tip

By default, Rust variables are immutable, meaning their values cannot be changed.

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' 1
/opt/homebrew/bin
/Users/kyclark/.cargo/bin
/Users/kyclark/.local/bin
/usr/local/bin
/usr/bin
/bin
/usr/sbin
/sbin
1

$PATH tells bash to interpolate the variable. Use a pipe (|) to feed this to tr.

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; 1

#[test]
fn runs() {
    let mut cmd = Command::cargo_bin("hello").unwrap(); 2
    cmd.assert().success(); 3
}
1

Import assert_cmd::Command.

2

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.

3

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); 1
}
1

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 1
$ echo $?
0
1

The --bin option is the name of the binary target to run.

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); 1
}
1

Exit with any value between 1 and 255 to indicate an error.

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(); 1
}
1

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; 1

#[test]
fn runs() {
    let mut cmd = Command::cargo_bin("hello").unwrap();
    let output = cmd.output().expect("fail"); 2
    assert!(output.status.success()); 3
    let stdout = String::from_utf8(output.stdout).expect("invalid UTF-8"); 4
    assert_eq!(stdout, "Hello, world!\n"); 5
}
1

Import the pretty_assertions::assert_eq macro for comparing values instead of the standard Rust version.

2

Call Command::output to execute the hello command. Use Result::expect to get the output of the command or die with the message “fail.”

3

Verify that the command succeeded.

4

Convert the output of the program to UTF-8, which I’ll discuss more in Chapter 4.

5

Compare the output from the program to an expected value. Note that this will use the pretty_assertions version of the assert_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, 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.

  • 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 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.

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.