Skip to content
◢ DISPATCH / RUST

Learning Rust with the Book: From Installation to the Guessing Game

Posted on October 17, 2026·Updated on October 17, 2026·33 MIN READ· VIEWS

◢ This post is also available in: 日本語

Learning Rust with the Book: From Installation to the Guessing Game

About this series

The Rust Programming Language, known as "the Book", is Rust's official introductory book. It is free to read on the Rust website, and each chapter has you write and run code.

This series works through the Book from the start, actually doing what it says. Rather than copying its text and code, these posts show what happened when I ran the Book's code, and what happened when I changed it on purpose. Rust's compiler errors are unusually helpful, so seeing "write this and it complains like that" is, I think, the best way to learn. The Book is licensed under MIT or Apache-2.0, at your option (COPYRIGHT in its repository).

This first post covers chapters 1 and 2 of the Book:

  • Chapter 1: install Rust, run Hello, World!, use Cargo
  • Chapter 2: build a guessing game

The published Book's title page assumes Rust 1.97.0 or later with edition = "2024". Everything here was tried with Rust 1.99.1 and mise 2026.10.11, in PowerShell on Windows 11.

Installing Rust with mise

The Book tells you to install Rust with rustup, the official tool that installs Rust's compiler and tools by version and switches between them. This blog manages its tools with mise, so Rust goes in through mise too.

mise does not keep Rust itself, though. According to mise's docs, mise installs Rust through rustup, installing rustup first if it is missing. Rust lands in rustup's homes (~\.rustup and ~\.cargo by default), and mise only keeps a link to them.

So as not to disturb my everyday setup, this recording pointed mise's and rustup's homes at a separate folder (C:\Users\Jam\rust-demo\home) and started from nothing. Where the output shows those paths, a normal setup would show ~\.cargo and so on.

mise use -g rust

mise use -g rust installs Rust globally. -g writes to the global configuration file, which applies in every folder. Without a version, you get the latest stable release.

terminal
❯ mise use -g rust
...
  rust@1.99.1  Downloading rustup-init  115.7s  12.7/12.7 MB · 111 kB/s
mise rust@1.99.1   1.99.1-x86_64-pc-windows-msvc installed - (timeout reading rustc version)
mise rust@1.99.1 info: default toolchain set to 1.99.1-x86_64-pc-windows-msvc
mise rust@1.99.1 info: checking for self-update (current version: 1.29.1)
mise rust@1.99.1 rustc 1.99.1 (58cf8f9c3 2026-10-13)
mise ✓ rust@1.99.1  136.0s
mise ████████████████ 1/1 · installed 1 tool in 136.0s
mise C:\Users\Jam\rust-demo\home\mise-config\config.toml tools: rust@1.99.1
1 commands10 lines

It downloaded rustup-init (12.7 MB) and installed Rust 1.99.1. The rustup lines are mixed in because mise is calling rustup. The timeout reading rustc version on the installed line means rustup timed out reading the version of the rustc it had just installed; the following lines read it fine.

The configuration file now says latest. In this recording its location was also moved, through the MISE_GLOBAL_CONFIG_FILE environment variable; normally it is ~\.config\mise\config.toml.

terminal
❯ Get-Content $env:MISE_GLOBAL_CONFIG_FILE
[tools]
rust = "latest"
1 commands3 lines

Checking what was installed

rustc is the compiler, cargo the build and package tool, and rustup the version manager.

terminal
❯ rustc --version
rustc 1.99.1 (58cf8f9c3 2026-10-13)
❯ cargo --version
cargo 1.99.1 (5f94df478 2026-08-27)
❯ rustup --version
rustup 1.29.1 (d95a37b6a 2026-08-13)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: the currently active `rustc` version is `rustc 1.99.1 (58cf8f9c3 2026-10-13)`
❯ rustup toolchain list
1.99.1-x86_64-pc-windows-msvc (active, default)
4 commands10 lines

On mise's side, mise ls rust lists it as a "symlink".

terminal
❯ mise ls rust
rust  1.99.1 (symlink)  C:\Users\Jam\rust-demo\home\mise-config\config.toml  latest
❯ Get-Item "$env:MISE_DATA_DIR\installs\rust\1.99.1" | Select-Object LinkType, Target | Format-List

LinkType : Junction
Target   : C:\Users\Jam\rust-demo\home\cargo\bin
2 commands6 lines

mise's install directory is a junction (a Windows folder link) to rustup's cargo\bin. mise tells rustup which version to use through the RUSTUP_TOOLCHAIN environment variable.

terminal
❯ mise env | Select-String RUSTUP_TOOLCHAIN

${Env:RUSTUP_TOOLCHAIN}='1.99.1'
1 commands3 lines

So even when installed through mise, it is rustup doing the work. What mise adds is that the version lives in the same configuration file as your other tools.

Windows needs a linker and Windows' libraries

The x86_64-pc-windows-msvc in rustup toolchain list is the name of a Rust target: "build for 64-bit Windows, the way Microsoft's MSVC does". The Rust side of it is already in place after mise use -g rust.

What is missing lives outside Rust. The MSVC target borrows two things from Visual Studio: the linker, which joins compiled pieces into an .exe, and Windows' standard libraries (the C runtime and the Windows SDK). The Book's installation section also says to install Visual Studio on Windows.

The machine used here already had Visual Studio's C++ build tools. vswhere, which comes with the Visual Studio installer, shows that.

terminal
❯ & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -products * -property displayName
Visual Studio Build Tools 2026
❯ & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools
2 commands4 lines

The second command looks for a Visual Studio that includes the C++ tools (VC.Tools.x86.x64). If it prints nothing, they are not installed.

The standard way: winget

mise's registry has no entry for Visual Studio's build tools. The standard way is the winget one-liner from rustup's docs.

powershell
winget install --id Microsoft.VisualStudio.Community --source winget --force --override "--add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.VC.Tools.ARM64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --addProductLang En-us"

It installs Visual Studio Community together with the C++ tools (x64 and ARM64), the Windows 11 SDK and the English language pack. Community is free for individuals. According to rustup's docs, if you already have a Visual Studio license you may replace Community with BuildTools. This machine already had the tools, so I did not run this command.

Doing it with mise: xwin and rust-lld

There is also a way to get by with mise alone, without installing Visual Studio. xwin downloads just the MSVC C runtime and the Windows SDK libraries from Microsoft and unpacks them into a folder. The linker is rust-lld, which ships with Rust. It is not an official route, but in the recording environment it built and ran the guessing game.

xwin is not in mise's registry, but the github: form, which installs from a GitHub release, works.

terminal
❯ mise use -g github:Jake-Shadle/xwin
...
mise ✓ github:Jake-Shadle/xwin@0.10.0  19.7s  xwin-0.10.0-x86_64-pc-windows-msvc.tar.gz
mise ████████████████ 1/1 · installed 1 tool in 19.7s
mise C:\Users\Jam\rust-demo\home\mise-config\config.toml tools: github:Jake-Shadle/xwin@0.10.0
❯ xwin --version
xwin 0.10.0
2 commands7 lines

xwin splat downloads the libraries and unpacks them. --accept-license means accepting Microsoft's license, so read it before adding the flag. --disable-symlinks skips the symbolic links xwin would add to paper over upper/lower-case differences: Windows file systems are case-insensitive, so they are not needed, and creating symbolic links on Windows can require extra privileges.

terminal
❯ xwin --accept-license --cache-dir C:\Users\Jam\rust-demo\home\xwin-cache splat --disable-symlinks --output C:\Users\Jam\rust-demo\home\xwin
❯ Get-ChildItem -Name xwin, xwin\crt\lib, xwin\sdk\lib
crt
sdk
x86_64
10.0.26100
ucrt
um
2 commands8 lines

On this connection it took 666 seconds. The unpacked libraries come to 618 MB, plus 1.1 GB of download cache (in the --cache-dir folder), which can be deleted afterwards.

What remains is telling Cargo about the linker and where the libraries are, in the project's .cargo\config.toml.

.cargo/config.tomltoml
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
rustflags = [
  "-Lnative=C:/Users/Jam/rust-demo/home/xwin/crt/lib/x86_64",
  "-Lnative=C:/Users/Jam/rust-demo/home/xwin/sdk/lib/um/x86_64",
  "-Lnative=C:/Users/Jam/rust-demo/home/xwin/sdk/lib/ucrt/x86_64",
]

With that, xwin_game, a copy of the guessing game built later in this post, builds.

terminal
❯ cargo build
...
   Compiling xwin_game v0.1.0 (C:\Users\Jam\rust-demo\work\xwin_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 20s
❯ @("abc") + (1..100) | .\target\debug\xwin_game.exe | Select-Object -Last 2
You guessed: 94
You win!
2 commands7 lines

It built and ran. This machine has Visual Studio as well, though, so it is worth checking that xwin was really the one used. cargo build -v prints the arguments Cargo passed. With unchanged sources it would not rebuild and would print nothing, so main.rs gets a fresh timestamp first.

terminal
❯ (Get-Item src\main.rs).LastWriteTime = Get-Date
❯ cargo build -v 2>&1 | Select-String -Pattern 'Running' | ForEach-Object { [regex]::Matches($_.Line, '-C linker=\S+|-Lnative=\S+') | ForEach-Object Value } | Select-Object -Unique
-C linker=rust-lld
-Lnative=C:/Users/Jam/rust-demo/home/xwin/crt/lib/x86_64
-Lnative=C:/Users/Jam/rust-demo/home/xwin/sdk/lib/um/x86_64
-Lnative=C:/Users/Jam/rust-demo/home/xwin/sdk/lib/ucrt/x86_64`
2 commands6 lines

The linker was rust-lld. Asking the linker to report which libraries it read (/verbose) shows every one coming from the xwin folder, and not a single Visual Studio or Windows Kits path.

terminal
❯ (Get-Item src\main.rs).LastWriteTime = Get-Date
❯ $log = cargo rustc -v -- -C link-arg=/verbose 2>&1
❯ $log | Select-String -Pattern 'Reading .*\.lib' | ForEach-Object { ($_.Line -replace '^.*Reading ', '') } | Sort-Object -Unique | Select-Object -First 8
C:/Users/Jam/rust-demo/home/xwin/crt/lib/x86_64\msvcrt.lib
C:/Users/Jam/rust-demo/home/xwin/crt/lib/x86_64\vcruntime.lib
C:/Users/Jam/rust-demo/home/xwin/sdk/lib/ucrt/x86_64\ucrt.lib
C:/Users/Jam/rust-demo/home/xwin/sdk/lib/um/x86_64\dbghelp.lib
C:/Users/Jam/rust-demo/home/xwin/sdk/lib/um/x86_64\kernel32.lib
C:/Users/Jam/rust-demo/home/xwin/sdk/lib/um/x86_64\ntdll.lib
C:/Users/Jam/rust-demo/home/xwin/sdk/lib/um/x86_64\userenv.lib
C:/Users/Jam/rust-demo/home/xwin/sdk/lib/um/x86_64\ws2_32.lib
❯ ($log | Select-String -Pattern 'Microsoft Visual Studio|Windows Kits' | Measure-Object).Count
0
4 commands13 lines

Checking on a machine without Visual Studio

Still, all of that ran on a machine with Visual Studio, so it does not show that the build works without it. To check that, I used a GitHub Actions Windows runner (windows-latest). The runner ships with Visual Studio, so the job first renames the Visual Studio, Windows Kits (Windows SDK) and LLVM folders to make them unusable. What follows are excerpts from that job's log.

Before the rename, the runner had Visual Studio, LLVM's clang-cl, and a link.exe from Git.

text
Visual Studio Enterprise 2026
C:\Program Files\Git\usr\bin\link.exe
C:\Program Files\LLVM\bin\clang-cl.exe

After it, vswhere, Windows Kits and LLVM are all gone, and neither cl.exe nor clang-cl.exe is on PATH.

text
False
False
False
cl.exe on PATH: False
clang-cl.exe on PATH: False

In that state, mise installed Rust 1.99.1 and xwin, and the guessing game was first built without the .cargo\config.toml. It failed, as expected.

text
error: linking with `link.exe` failed: exit code: 1
  = note: link: extra operand 'D:\\a\\_temp\\work\\guess\\target\\debug\\build\\getrandom-a6217cf0cd42cfa1\\build_script_build-a6217cf0cd...
          Try 'link --help' for more information.
note: you may need to install Visual Studio build tools with the "C++ build tools" workload

The interesting part is that link.exe was not missing: a different link.exe from Git for Windows (the Unix command that links files) got called. It has nothing to do with Rust's linker, hence the confused extra operand error. A machine with Git but no Visual Studio may well hit this.

Next, xwin splat unpacked the libraries (78 seconds on the runner), and with a .cargo\config.toml like the one above (only the xwin path changed for the runner) the build went through.

text
   Compiling xwin_game v0.1.0 (D:\a\_temp\work\guess)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.20s
You guessed: 87
You win!
libraries read:
D:/a/_temp/xwin/crt/lib/x86_64\msvcrt.lib
D:/a/_temp/xwin/crt/lib/x86_64\vcruntime.lib
D:/a/_temp/xwin/sdk/lib/ucrt/x86_64\ucrt.lib
D:/a/_temp/xwin/sdk/lib/um/x86_64\dbghelp.lib
D:/a/_temp/xwin/sdk/lib/um/x86_64\kernel32.lib
D:/a/_temp/xwin/sdk/lib/um/x86_64\ntdll.lib
D:/a/_temp/xwin/sdk/lib/um/x86_64\userenv.lib
D:/a/_temp/xwin/sdk/lib/um/x86_64\ws2_32.lib
Visual Studio or Windows Kits paths: 0

On Windows without Visual Studio, mise, xwin and rust-lld alone built and ran the guessing game.

Crates containing C code

Every crate the guessing game uses is pure Rust. Some crates, though, contain C code and call a C compiler while building. xwin only provides libraries and headers; it has no C compiler.

To check this, I made a small crate, cdemo, that calls one C function. build.rs is a script Cargo runs before building; here it uses the cc crate to compile greet.c.

src/greet.ctext
#include <string.h>

int c_len(const char *text) {
    return (int)strlen(text);
}
build.rsrust
fn main() {
    cc::Build::new().file("src/greet.c").compile("greet");
}
src/main.rsrust
use std::ffi::{c_char, c_int};

unsafe extern "C" {
    fn c_len(text: *const c_char) -> c_int;
}

fn main() {
    let len = unsafe { c_len(c"Hello from C".as_ptr()) };
    println!("C says the text is {len} bytes long");
}

unsafe and extern "C" are how Rust calls C functions, covered in the Book's chapter 20; the details do not matter here.

On the same runner, with only the xwin configuration, this crate failed to build because no C compiler was found.

text
  error occurred in cc-rs: failed to find tool "cl.exe": program not found

The C compiler can come from mise too. The clang in mise's registry (conda:clang, which installs the conda-forge package) includes clang-cl, which compiles the MSVC way. For bundling the compiled C into a static library, llvm-ar from Rust's llvm-tools component does the job.

powershell
mise use -g conda:clang
rustup component add llvm-tools

Environment variables tell the cc crate which compiler to use and where the headers are. /imsvc is the clang-cl option that reads the folders xwin unpacked as system headers.

powershell
$x = "C:/Users/Jam/rust-demo/home/xwin"
$env:CC_x86_64_pc_windows_msvc = Join-Path (mise where conda:clang) "Library\bin\clang-cl.exe"
$env:AR_x86_64_pc_windows_msvc = (Get-ChildItem -Recurse -Filter llvm-ar.exe (rustc --print sysroot)).FullName | Select-Object -First 1
$env:CFLAGS_x86_64_pc_windows_msvc = "/imsvc $x/crt/include /imsvc $x/sdk/include/ucrt /imsvc $x/sdk/include/um /imsvc $x/sdk/include/shared"

On the runner, that built and ran the crate that calls C as well (xwin's folder there is D:/a/_temp/xwin).

text
CC = C:\Users\runneradmin\AppData\Local\mise\installs\conda-clang\23.1.3\Library\bin\clang-cl.exe
AR = C:\Users\runneradmin\.rustup\toolchains\1.99.1-x86_64-pc-windows-msvc\lib\rustlib\x86_64-pc-windows-msvc\bin\llvm-ar.exe
   Compiling cdemo v0.1.0 (D:\a\_temp\work\cdemo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
C says the text is 12 bytes long

Which to choose

xwin's README describes it as a tool for compiling and linking Windows programs from a non-Windows platform, so using it on Windows is off its intended path. It worked for everything tried here, but larger crates and crates with C++ were not tried. When in doubt, installing Visual Studio with winget is the dependable choice; reach for xwin when you want everything from mise or would rather not install Visual Studio.

Reading the Book locally

Rust installed through rustup ships with a copy of the Book. rustup doc --book opens it in a browser, and --path prints where it is instead of opening it.

terminal
❯ rustup doc --path --book
C:\Users\Jam\rust-demo\home\rustup\toolchains\1.99.1-x86_64-pc-windows-msvc\share\doc\rust\html\book/index.html
1 commands2 lines

The local copy matches the installed Rust version and works offline.

Hello, World! (section 1.2)

First, without Cargo, using the compiler rustc directly. In a folder called hello_world, write main.rs.

main.rsrust
fn main() {
    println!("Hello, world!");
}

fn main() is the function the program starts from. rustc main.rs compiles it.

terminal
❯ rustc main.rs
❯ Get-ChildItem -Name
main.exe
main.pdb
main.rs
❯ .\main.exe
Hello, world!
3 commands7 lines

It finished without printing anything and left main.exe and main.pdb behind. main.exe is the executable and main.pdb holds debugging information. Unlike JavaScript or Python, Rust compiles to an executable first and then runs that.

terminal
❯ Get-ChildItem main.exe, main.pdb | Select-Object Name, Length | Format-Table -AutoSize

Name      Length
----      ------
main.exe  134144
main.pdb 1306624
1 commands6 lines

The executable is about 131 KB.

Functions and macros

The ! at the end of println! marks it as a macro, not a function. JavaScript and Python have no macros, so a short explanation is in order.

A function takes a fixed number of arguments of fixed types and is called while the program runs. fn main() is a function.

A macro rewrites the code you wrote into other code before compilation. The Book's macros section calls it "writing code that writes other code". At compile time, println!("Hello, world!") expands into longer code that formats the text and writes it out. The compiler does the expanding, so you normally never see the expanded code.

The difference shows most clearly in the number of arguments. A Rust function takes a fixed number of them. Here greet takes one argument and is given two.

fn_args.rsrust
fn greet(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    greet("Rust");
    greet("Rust", 11);
}
terminal
❯ rustc fn_args.rs
error[E0061]: this function takes 1 argument but 2 arguments were supplied
 --> fn_args.rs:7:5
  |
7 |     greet("Rust", 11);
  |     ^^^^^         -- unexpected argument #2 of type `{integer}`
  |
note: function defined here
 --> fn_args.rs:1:4
  |
1 | fn greet(name: &str) {
  |    ^^^^^
help: remove the extra argument
  |
7 -     greet("Rust", 11);
7 +     greet("Rust");
  |

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0061`.
1 commands21 lines

"This function takes 1 argument but 2 were supplied." Rust has no optional parameters like TypeScript's name?: string, and no variable-length parameters like JavaScript's ...args.

println!, on the other hand, takes any number of arguments.

varargs.rsrust
fn main() {
    let name = "Rust";
    let age = 11;
    println!("Hello!");
    println!("Hello, {name}!");
    println!("Hello, {}! You are {} years old.", name, age);
}
terminal
❯ rustc varargs.rs
❯ .\varargs.exe
Hello!
Hello, Rust!
Hello, Rust! You are 11 years old.
2 commands5 lines

One argument or three, it works. As the second line shows, a variable's name can even go straight inside the string as {name}. A macro reads the code you wrote before it is compiled, which is what makes that possible.

The other benefit is that mistakes surface at compile time. What if there are two {} but only one argument?

terminal
❯ Get-Content too_few.rs
fn main() {
    let name = "Rust";
    println!("Hello, {}! You are {} years old.", name);
}
❯ rustc too_few.rs
error: 2 positional arguments in format string, but there is 1 argument
 --> too_few.rs:3:22
  |
3 |     println!("Hello, {}! You are {} years old.", name);
  |                      ^^          ^^              ----

error: aborting due to 1 previous error
2 commands13 lines

It stopped before the program ever ran: "two {} in the string, but one argument". While expanding, println! reads the format string and matches its {} against the arguments. With an ordinary function the compiler cannot see inside the string, so it could not do this.

Macros come up again and again in this series, and the Book's chapter 20 covers writing your own. For now it is enough to know that a name ending in ! is a macro, and that it expands into code before compilation.

Taking out the ! in println

To see what happens without the !, no_bang.rs changes it to println(.

terminal
❯ Get-Content no_bang.rs
fn main() {
    println("Hello, world!");
}
❯ rustc no_bang.rs
error[E0423]: cannot find function `println` in this scope
 --> no_bang.rs:2:5
  |
2 |     println("Hello, world!");
  |     ^^^^^^^ not found in this scope
  |
  = note: a macro named `println` exists in another namespace
help: use `!` to invoke the macro
  |
2 |     println!("Hello, world!");
  |            +

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0423`.
2 commands20 lines

"There is no function called println, but there is a macro by that name; add ! to call it." It even shows the fix: the + line marks where the character goes. Errors carry codes like E0423, and rustc --explain E0423 prints a longer explanation.

Taking out the trailing ;

Next, no_semi.rs drops the ; at the end of the line.

terminal
❯ Get-Content no_semi.rs
fn main() {
    println!("Hello, world!")
}
❯ rustc no_semi.rs
❯ .\no_semi.exe
Hello, world!
3 commands7 lines

No error this time. In Rust the last expression in a block may go without ;, and its value becomes the block's value. println! returns the "nothing" value (), which is also what main returns, so it is fine. Chapter 3 covers this properly, and the next post comes back to it.

Hello, Cargo! (section 1.3)

Real Rust projects rarely call rustc directly; they use Cargo. Cargo builds, runs, tests and manages the packages a project depends on (in Rust, "crates"). In JavaScript terms it is npm and a build tool in one.

cargo new creates a project.

terminal
❯ cargo new hello_cargo
    Creating binary (application) `hello_cargo` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
❯ cd hello_cargo
❯ Get-ChildItem -Force -Name
.git
src
.gitignore
Cargo.toml
3 commands9 lines

The source goes under src, and a Git repository (.git) and .gitignore came with it. .gitignore lists only /target, where build output goes.

terminal
❯ Get-Content Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]
❯ Get-Content src\main.rs
fn main() {
    println!("Hello, world!");
}
❯ Get-Content .gitignore
/target
3 commands13 lines

Cargo.toml is the project's configuration, the counterpart of npm's package.json. edition = "2024" selects the Rust language edition: every few years Rust groups changes that break compatibility into a new edition. cargo new defaults to 2024 (cargo new docs). [dependencies] is where crates go; that comes up shortly.

build, run and check

cargo build builds the project.

terminal
❯ cargo build
   Compiling hello_cargo v0.1.0 (C:\Users\Jam\rust-demo\work\hello_cargo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.06s
❯ Get-ChildItem -Name
src
target
.gitignore
Cargo.lock
Cargo.toml
❯ .\target\debug\hello_cargo.exe
Hello, world!
3 commands11 lines

The executable is in target\debug. A Cargo.lock appeared as well; it pins the versions of dependencies, like npm's package-lock.json. With no dependencies yet, it only lists the project itself.

terminal
❯ Get-Content Cargo.lock
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "hello_cargo"
version = "0.1.0"
3 commands8 lines

cargo run builds and runs in one step.

terminal
❯ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.17s
     Running `target\debug\hello_cargo.exe`
Hello, world!
1 commands4 lines

The source had not changed, so it skipped the build (no Compiling line) and ran the existing executable. After changing the text in src\main.rs to Hello, Cargo!, it builds again.

terminal
❯ cargo run
   Compiling hello_cargo v0.1.0 (C:\Users\Jam\rust-demo\work\hello_cargo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.84s
     Running `target\debug\hello_cargo.exe`
Hello, Cargo!
1 commands5 lines

cargo check only checks that the code compiles and produces no executable. That makes it faster, so it is the one to run over and over while writing.

terminal
❯ cargo check
    Checking hello_cargo v0.1.0 (C:\Users\Jam\rust-demo\work\hello_cargo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.11s
1 commands3 lines

Cargo calls rustc for you

rustc is the Rust compiler itself: it takes .rs files and produces executables and libraries. Cargo is not a compiler. It decides which files to compile, in what order and with which settings, and then calls rustc. In TypeScript terms, rustc is tsc, and Cargo is npm plus the scripts that call tsc.

cargo build -v shows the commands Cargo actually runs.

terminal
❯ cargo clean
     Removed 25 files, 2.9MiB total
❯ cargo build -v
   Compiling hello_cargo v0.1.0 (C:\Users\Jam\rust-demo\work\hello_cargo)
     Running `C:\Users\Jam\rust-demo\home\rustup\toolchains\1.99.1-x86_64-pc-windows-msvc\bin\rustc.exe --crate-name hello_cargo --edition=2024 src\main.rs --error-format=json --json=diagnostic-rendered-ansi,artifacts,future-incompat --crate-type bin --emit=dep-info,link -C embed-bitcode=no -C debuginfo=2 --check-cfg cfg(docsrs,test) --check-cfg "cfg(feature, values())" -C metadata=cb559adae4f42d23 --out-dir C:\Users\Jam\rust-demo\work\hello_cargo\target\debug\deps -C incremental=C:\Users\Jam\rust-demo\work\hello_cargo\target\debug\incremental -L dependency=C:\Users\Jam\rust-demo\work\hello_cargo\target\debug\deps`
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.48s
2 commands6 lines

At its core this is rustc.exe ... src\main.rs, the same thing as the rustc main.rs typed by hand earlier. The difference is everything around it: edition = "2024" from Cargo.toml becomes --edition=2024, and Cargo adds where the output goes (--out-dir) and the setting that lets it recompile only what changed (-C incremental). Nobody wants to type that every time, so Cargo does it.

Release builds

cargo build --release builds with optimizations, for release.

terminal
❯ cargo build --release
   Compiling hello_cargo v0.1.0 (C:\Users\Jam\rust-demo\work\hello_cargo)
    Finished `release` profile [optimized] target(s) in 2.38s
❯ Get-ChildItem -Name target
debug
release
.rustc_info.json
CACHEDIR.TAG
2 commands8 lines

dev and release go to target\debug and target\release. dev builds faster but runs slower; release is the other way round.

Building the guessing game (chapter 2)

Chapter 2 builds a game where you guess a secret number between 1 and 100. The Book adds code a little at a time and touches most of Rust's basics on the way. This post follows the same order.

terminal
❯ cargo new guessing_game
    Creating binary (application) `guessing_game` package
1 commands2 lines

The game reads numbers from standard input, so here they are piped in from PowerShell. That is why the input values themselves do not appear on screen.

Reading input

The first version reads one line and prints it back.

src/main.rsrust
use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}
terminal
❯ "42" | cargo run
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.00s
     Running `target\debug\guessing_game.exe`
Guess the number!
Please input your guess.
You guessed: 42
1 commands8 lines

The empty line at the end is there because read_line keeps the line's trailing newline in guess. That matters later.

Three very Rust things are packed in here:

  • let mut guess: Rust variables are immutable by default; only mut makes them changeable
  • &mut guess: guess is passed to read_line as a reference it may modify. & makes a reference, which leads into chapter 4's ownership
  • .expect(...): read_line returns a Result, which says whether it succeeded. expect stops the program with its message if it failed

Removing mut

What if let mut guess becomes let guess?

terminal
❯ cargo build
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
error[E0596]: cannot borrow `guess` as mutable, as it is not declared as mutable
  --> src\main.rs:11:20
   |
11 |         .read_line(&mut guess)
   |                    ^^^^^^^^^^ cannot borrow as mutable
   |
help: consider changing this to be mutable
   |
 8 |     let mut guess = String::new();
   |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error
1 commands15 lines

"It was not declared mutable, so it cannot be borrowed as mutable." TypeScript's const only stops you from assigning a different value to the variable; the object inside can still change. A Rust variable without mut cannot have its contents changed either (types such as Cell deliberately allow it; the Book covers them in chapter 15).

Removing expect

Now put mut back and delete the .expect(...) line.

terminal
❯ cargo build
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
warning: unused `Result` that must be used
  --> src\main.rs:10:5
   |
10 | /     io::stdin()
11 | |         .read_line(&mut guess);
   | |______________________________^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
   |
10 |     let _ = io::stdin()
   |     +++++++

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.15s
1 commands18 lines

A warning rather than an error, and the build went through. It says a Result that might be a failure is being thrown away unchecked, and suggests let _ = if ignoring it is really intended. Rust does not like failures being dropped silently.

Adding rand

The secret number needs randomness, and according to the Book, Rust's standard library does not yet include random number functionality. So the game uses a crate called rand. As the Book does, add it by hand under [dependencies] in Cargo.toml.

Cargo.tomltoml
[dependencies]
rand = "0.10.1"

cargo add rand would also add it, but writes the newest version (it wrote rand = "0.10.3" when I tried). To match the Book, the build below keeps the hand-written 0.10.1.

terminal
❯ cargo build
    Updating crates.io index
     Locking 8 packages to highest Rust 1.99.1 compatible versions
      Adding cfg-if v1.0.5
      Adding chacha20 v0.10.2
      Adding cpufeatures v0.3.1
      Adding getrandom v0.4.3
      Adding libc v0.2.191
      Adding r-efi v6.0.0
      Adding rand v0.10.3
      Adding rand_core v0.10.1
 Downloading crates ...
  Downloaded cfg-if v1.0.5
  Downloaded cpufeatures v0.3.1
  Downloaded chacha20 v0.10.2
  Downloaded getrandom v0.4.3
  Downloaded rand_core v0.10.1
  Downloaded rand v0.10.3
   Compiling rand_core v0.10.1
   Compiling getrandom v0.4.3
   Compiling cfg-if v1.0.5
   Compiling cpufeatures v0.3.1
   Compiling chacha20 v0.10.2
   Compiling rand v0.10.3
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.86s
1 commands26 lines

Cargo fetched rand and the crates it uses from crates.io (Rust's package registry, like npm's) and built them.

Two things stand out. First, 0.10.1 was written but rand v0.10.3 was installed. In Cargo, "0.10.1" means "0.10.1 or later, before 0.11.0" (Cargo docs). It is the same idea as npm's ^0.10.1: the newest compatible version wins. The chosen version is recorded in Cargo.lock, so later builds keep using it.

terminal
❯ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
❯ Select-String -Path Cargo.lock -Pattern '^name = "rand"' -Context 0,2 | ForEach-Object { $_.Line; $_.Context.PostContext }
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
2 commands6 lines

Second, it says Locking 8 packages but downloads and compiles only six. libc and r-efi are for other operating systems, so Windows does not need them. cargo tree shows the dependency tree.

terminal
❯ cargo tree
guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
└── rand v0.10.3
    ├── chacha20 v0.10.2
    │   ├── cfg-if v1.0.5
    │   ├── cpufeatures v0.3.1
    │   └── rand_core v0.10.1
    ├── getrandom v0.4.3
    │   ├── cfg-if v1.0.5
    │   └── rand_core v0.10.1
    └── rand_core v0.10.1
1 commands11 lines

More dependencies mean more rustc calls. Here are just the crate names Cargo passed to rustc, taken from the cargo build -v output.

terminal
❯ cargo clean
     Removed 110 files, 13.4MiB total
❯ cargo build -v 2>&1 | Select-String -Pattern '--crate-name (\S+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }
rand_core
cpufeatures
build_script_build
cfg_if
chacha20
getrandom
rand
guessing_game
2 commands11 lines

Cargo compiles each dependency before the crates that use it, and the game itself last. Crates that do not depend on each other compile in parallel, so the order shifts a little from run to run. build_script_build is the build script (build.rs) that getrandom ships. Lining all of this up in dependency order is Cargo's job too.

Making the secret number

use rand::prelude::*; brings in rand's commonly used items, and rand::rng().random_range(1..=100) makes a number from 1 to 100. 1..=100 includes 100 (1..100 would not). For now the secret number is printed, to make checking easy.

src/main.rsrust
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    // --snip--
}
terminal
❯ "50" | cargo run
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.38s
     Running `target\debug\guessing_game.exe`
Guess the number!
The secret number is: 81
Please input your guess.
You guessed: 50
1 commands9 lines

The secret number changes on every run.

Removing the use

What happens without the use rand::prelude::*; line?

terminal
❯ cargo build
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
error[E0599]: no method named `random_range` found for struct `ThreadRng` in the current scope
   --> src\main.rs:6:37
    |
  6 |     let secret_number = rand::rng().random_range(1..=100);
    |                                     ^^^^^^^^^^^^
    |
   ::: C:\Users\Jam\rust-demo\home\cargo\registry\src\index.crates.io-1949cf8c6b5b557f\rand-0.10.3\src\rng.rs:163:8
    |
163 |     fn random_range<T, R>(&mut self, range: R) -> T
    |        ------------ the method is available for `ThreadRng` here
    |
    = help: items from traits can only be used if the trait is in scope
help: there is a method `random` with a similar name, but with different arguments
   --> C:\Users\Jam\rust-demo\home\cargo\registry\src\index.crates.io-1949cf8c6b5b557f\rand-0.10.3\src\rng.rs:93:5
    |
 93 | /     fn random<T>(&mut self) -> T
 94 | |     where
 95 | |         StandardUniform: Distribution<T>,
    | |_________________________________________^
help: trait `RngExt` which provides `random_range` is implemented but not in scope; perhaps you want to import it
    |
  1 + use rand::RngExt;
    |

For more information about this error, try `rustc --explain E0599`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error
1 commands28 lines

rand::rng() works, yet the method random_range cannot be found. The help lines explain why: random_range belongs to a trait called RngExt, and a trait's methods are only usable once the trait is brought in with use. rand::prelude::* had been bringing in RngExt along with everything else. Traits are chapter 10's subject; for now, think of them as a way to add methods to a type, methods that only work once the trait is brought in with use.

Comparing: different types cannot be compared

Now compare the guess with the secret number, using std::cmp::Ordering and a match that splits into "less", "greater" and "equal".

src/main.rsrust
use std::cmp::Ordering;

// ...

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }

This does not compile. It is the high point of chapter 2.

terminal
❯ cargo build
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
error[E0308]: mismatched types
  --> src\main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library\core\src\cmp.rs:1012:7

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error
1 commands17 lines

guess is the input as a string (String), and secret_number is an integer. A string and an integer cannot be compared. JavaScript would quietly turn "50" < 81 into a numeric comparison; Rust never converts between types behind your back.

Turning the string into a number

The fix is to convert the input before comparing. The Book does it by creating a new variable with the same name, guess.

src/main.rsrust
    let guess: u32 = guess.trim().parse().expect("Please type a number!");

Declaring a variable with the same name again hides the earlier one. This is called shadowing, and it saves inventing names like guess_str and guess_num. trim() removes surrounding whitespace and the newline, parse() converts to a number, and u32 is an unsigned 32-bit integer.

terminal
❯ "50" | cargo run
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.28s
     Running `target\debug\guessing_game.exe`
Guess the number!
The secret number is: 56
Please input your guess.
You guessed: 50
Too small!
1 commands9 lines

Now the comparison works, and the empty line after You guessed: 50 is gone too, because trim() removed the newline.

Interestingly, secret_number never had its type written down. Since guess is a u32 and guess.cmp(&secret_number) compares the two, Rust infers that secret_number must be a u32 as well.

Input that is not a number stops the program with the expect message.

terminal
❯ "abc" | cargo run -q
Guess the number!
The secret number is: 34
Please input your guess.

thread 'main' (836) panicked at src\main.rs:21:43:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
1 commands8 lines

-q hides Cargo's own output such as Compiling. Stopping like this is what Rust calls a panic.

Removing trim, then the type annotation

Without trim(), even 50 stops the program.

terminal
❯ "50" | cargo run -q
Guess the number!
The secret number is: 64
Please input your guess.

thread 'main' (15820) panicked at src\main.rs:21:36:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
1 commands8 lines

The newline read_line kept is still there, so it tried to convert "50 plus a newline" rather than "50". The empty line from the very first run explains this failure.

Without : u32, it does not compile at all.

terminal
❯ cargo build
   Compiling guessing_game v0.1.0 (C:\Users\Jam\rust-demo\work\guessing_game)
error[E0282]: type annotations needed
  --> src\main.rs:21:9
   |
21 |     let guess = guess.trim().parse().expect("Please type a number!");
   |         ^^^^^
...
25 |     match guess.cmp(&secret_number) {
   |           ----- type must be known at this point
   |
help: consider giving `guess` an explicit type
   |
21 |     let guess: /* Type */ = guess.trim().parse().expect("Please type a number!");
   |              ++++++++++++

For more information about this error, try `rustc --explain E0282`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error
1 commands18 lines

parse() can produce many types, and with nothing saying which one is wanted, it asks for a type. Earlier, Rust inferred secret_number's type, but when neither guess nor secret_number gives a clue there is nothing to infer from.

Looping, and shrugging off non-numbers

Finally, let the player guess until they get it. The Book's final version wraps everything in loop and leaves with break on a correct guess. So that non-numbers no longer stop the program, a match on the result of parse() replaces expect. The secret number is no longer printed. This is the same code as the Book's listing 2-6.

src/main.rsrust
use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

parse() returns a Result: Ok(number) on success, Err(error) on failure. Ok(num) => num takes the number out, and Err(_) => continue goes back to the top of the loop on failure. _ means "I do not need the value".

With the secret number hidden, the run below feeds in abc followed by 1 to 100 in order, until it hits.

terminal
❯ $out = @("abc") + (1..100) | cargo run -q
❯ $out | Select-Object -First 9
Guess the number!
Please input your guess.
Please input your guess.
You guessed: 1
Too small!
Please input your guess.
You guessed: 2
Too small!
Please input your guess.
❯ $out | Select-Object -Last 6
Please input your guess.
You guessed: 91
Too small!
Please input your guess.
You guessed: 92
You win!
3 commands18 lines

The two Please input your guess. lines in a row are where abc was shrugged off. It won at 92, left the loop after You win! and finished; 93 to 100 were never read.

A trap I hit: the game never stops when input runs out

Piping input in turned up one problem. If the input runs out before a correct guess, the program never stops. Here only 50 goes in, and only the first 8 lines are kept.

terminal
❯ "50" | cargo run -q | Select-Object -First 8
Guess the number!
Please input your guess.
You guessed: 50
Too small!
Please input your guess.
Please input your guess.
Please input your guess.
Please input your guess.
1 commands9 lines

Without Select-Object -First 8 to cut it off, Please input your guess. keeps coming forever. Once input is exhausted, read_line does not fail; it succeeds having read 0 bytes. guess stays empty, parse() fails, continue loops back, read_line reads 0 bytes again, and so on. Playing from the keyboard normally avoids this. Feeding input in a test hits the same thing, and ending input with Ctrl+Z and Enter on Windows should hit it too, by the same logic (I did not try that case).

Checking the byte count read_line returns and leaving the loop on 0 fixes it. To keep to the Book's flow, this post leaves it as is.

Commands at a glance

CommandDescription
mise use -g rustInstall the latest stable Rust globally
rustc --version / cargo --versionCheck the versions
rustup doc --bookOpen the local copy of the Book
mise use -g github:Jake-Shadle/xwinInstall xwin with mise
xwin --accept-license splat --disable-symlinks --output <dir>Unpack the MSVC and Windows SDK libraries
cargo new <name>Create a project
cargo build / cargo build --releaseBuild (for development / for release)
cargo runBuild and run
cargo checkOnly check that it compiles
cargo add <crate>Add a dependency to Cargo.toml
cargo treeShow the dependency tree
rustc --explain <error code>Read the longer explanation of an error

Summary

  • Installing Rust through mise still runs rustup underneath; mise passes the version on through RUSTUP_TOOLCHAIN
  • On Windows, the linker and Windows' libraries come from Visual Studio's C++ build tools, normally installed with winget. Using only tools from mise, xwin and rust-lld built and ran the game on a Windows runner with Visual Studio removed, and conda:clang's clang-cl handled a crate with C code
  • rustc on its own compiles to an executable (.exe); day to day, Cargo's build, run and check do the work
  • Cargo.toml is package.json and Cargo.lock is package-lock.json; "0.10.1" means "at least 0.10.1, below 0.11.0"
  • Variables are immutable by default, an ignored Result draws a warning, trait methods need a use, and values of different types cannot be compared
  • Compiler errors say not only what is wrong but how to fix it; breaking code and reading them is the best teacher

The next post covers chapter 3 of the Book: variables, data types, functions and control flow, including why the program still ran without that ;.

◢ RELATED DISPATCHES — Continue Exploring