This guide is the source-based reference for engineers who maintain
Rustlings, fork it for a language community, ship community exercise sets,
or integrate Rustlings into a curriculum or CI pipeline. It documents the
binary's architecture, the info.toml format, the watch-mode internals, the
rustlings dev toolchain, the workspace layout, and the contribution path.
1. Architecture Overview
Rustlings is a single Rust binary built from the rustlings package in a
two-crate workspace (rustlings and rustlings-macros). It is fully
self-contained — there is no daemon, no server, no shared library — and
delegates all heavy lifting (compile, test, lint) to cargo, rustc,
cargo-test, cargo-clippy, and rustfmt, which it invokes as
child processes.
When you cargo install rustlings and run rustlings init, the resulting
exercises directory is the Cargo project. Every <name>.rs exercise file
is registered as a binary target in dev/Cargo.toml's bin = […] list, so
cargo build --bin <name> is the unit of work for one exercise.
┌──────────────────────────┐
keyboard / file │ rustlings binary │
change events ─────► │ │
│ ┌───────────────────┐ │
│ │ src/main.rs │ │ parsed by clap
│ │ (CLI dispatch) │ │
│ └─────┬─────────────┘ │
│ │ │
│ ┌────▼─────────┐ │
│ │ AppState │ ◄── reads .rustlings-state.txt
│ │ (state-file) │ ──► writes .rustlings-state.txt
│ └────┬─────────┘ │
│ │ │
│ ┌────┴────────────┐ │
│ │ exercises[] │ ◄── EMBEDDED_FILES (rustlings-macros)
│ └────┬────────────┘ │
│ │ │
│ ┌────┴────────────┐ │
│ │ Run / Watch / │ │
│ │ List / Check-all│ │
│ └────┬────────────┘ │
│ │ │
│ ┌────▼─────────┐ │
│ │ CmdRunner │ │
│ │ (cargo / rustc) │ │
│ └────┬─────────┘ │
└────────┼─────────────────┘
│
┌─────▼──────┐ ┌────────────┐ ┌────────────┐
│ cargo build│ │ cargo test │ │ cargo clippy│
└────────────┘ └────────────┘ └────────────┘
Note
The exercise files, solution files, the
info.tomlregistry, and per-folder READMEs are all embedded into the binary at compile time by therustlings_macros::include_files!()proc-macro. That's why the end-user install flow does not need to clone the repository —rustlings initcan dump the entireexercises/andsolutions/tree from the binary itself. Seerustlings-macros/src/lib.rsandsrc/embedded.rs.
1.1 Component inventory
| Component | Path | Role |
|---|---|---|
| CLI dispatcher | src/main.rs |
Parse args, route to init/run/reset/hint/check-all/dev/watch. |
| Argument schema | src/cli.rs |
clap Parser/Subcommand definitions for the public CLI surface. |
| App state machine | src/app_state.rs |
The AppState struct: holds exercises, current index, done count, state file handle, embedded CmdRunner, and editor handle. |
| Cargo invoker | src/cmd.rs |
CmdRunner queries cargo metadata once at startup, then builds child processes for cargo build/test/clippy. |
| Embedded data | src/embedded.rs |
Wraps EMBEDDED_FILES (the proc-macro output) and exposes init_exercises_dir, write_exercise_to_disk, write_solution_to_disk. |
| Exercise model | src/exercise.rs |
Exercise struct, RunnableExercise trait. Encapsulates "build → test (if test=true) → clippy → run-bin". |
| Info file | src/info_file.rs |
ExerciseInfo/InfoFile structs and InfoFile::parse(). Supports both embedded and on-disk info.toml. |
| Init command | src/init.rs |
Logic for rustlings init. |
| Run command | src/run.rs |
One-shot run <name>. |
| Watch mode | src/watch.rs, src/watch/state.rs, src/watch/notify_event.rs, src/watch/terminal_event.rs |
The interactive default mode. |
| List view | src/list.rs, src/list/state.rs, src/list/scroll_state.rs |
Full-screen scrolling list with search, filter, reset, set-current. |
| Editor | src/editor.rs, src/editor/zellij.rs |
Editor opener (VS Code, custom, Zellij). |
| Terminal helpers | src/term.rs |
ANSI helpers, progress bar, file-link emitter, length-bounded writer. |
| Cargo.toml updater | src/cargo_toml.rs |
Parses and rewrites the bin = […] block in dev/Cargo.toml / Cargo.toml to match info.toml. |
| Dev toolchain | src/dev.rs, src/dev/check.rs, src/dev/new.rs, src/dev/update.rs |
rustlings dev new, rustlings dev check, rustlings dev update. |
| Embed macro | rustlings-macros/src/lib.rs |
#[proc_macro] pub fn include_files. Reads info.toml at build time, expands to a static EmbeddedFiles literal containing include_bytes!() calls for every exercise, solution, and README. |
| Build helper | build.rs |
On Windows only, copies dev/Cargo.toml to dev-Cargo.toml because Windows can't include the symlink. |
1.2 Technology stack
| Layer | Crate / tool | Purpose |
|---|---|---|
| CLI parsing | clap 4 (derive feature) |
Subcommands, args, version flag, doc-comment-derived help. |
| Error context | anyhow 1 |
Result/Error/bail!/Context for human-readable error chains. |
| Terminal | crossterm 0.29 |
ANSI styling, cursor motion, raw mode, key/mouse events. Default features off; only windows + events enabled. |
| File watching | notify 8 + RecommendedWatcher |
OS-native file events (inotify/FSEvents/ReadDirectoryChangesW), 1 s poll interval as fallback, follow-symlinks disabled. |
| Embed | rustlings-macros (this workspace) |
Proc-macro that bakes info.toml + every exercise/solution/README into the binary. |
| Serialization | serde 1 + serde_json 1 + toml 1 |
Parse cargo metadata, parse info.toml. |
| Shell-style splitting | shlex 1 |
Parse the --edit-cmd "PROGRAM …" string. |
| Termios (non-Windows) | rustix 1.0 (std+stdio+termios, default-features = false) |
Disable canonical line buffering and echo for the input side of watch mode. |
| Test temp files | tempfile 3 (dev-only) |
Used by unit tests in app_state.rs. |
The release profile sets panic = "abort" to keep the binary smaller. The
workspace-wide Clippy lints forbid unsafe_code, unstable_features, and
empty_loop, and deny disallowed-types/disallowed-methods,
infinite_loop, and mem_forget. Custom denials in clippy.toml redirect
contributors away from crossterm::style::Stylize, crossterm::style::style,
std::thread::spawn, std::thread::Scope::spawn, and std::process::exit.
1.3 Top-level data flow
The main function in src/main.rs runs the following sequence on every
invocation:
- Parse args with
clap(Args::parse()returning theArgsstruct). - Block the legacy workflow. In a release build, if a
dev/rustlings-repo.txtmarker file is present in the current directory, bail with the "old method before version 6" error. - Priority subcommands.
InitandDev(...)short-circuit through their handlers ininit::init()andDevCommand::run()respectively, then exitSUCCESS. - Verify location. If
exercises/doesn't exist, print thePRE_INIT_MSGASCII banner with usage instructions and exitFAILURE. - Parse
info.toml.InfoFile::parse()first tries to readinfo.tomlfrom the current dir (community track), falling back to the embeddedinfo.tomlbaked in byrustlings_macros::include_files!(). - Reject newer
format_version. Bail withFORMAT_VERSION_HIGHER_ERRifinfo_file.format_version > CURRENT_FORMAT_VERSION (1). - Detect editor.
Editor::new(args.edit_cmd, vs_code_term). ReturnsSome(Editor::Cmd(...))for VS Code or--edit-cmd,Some(Editor::Zellij(_))if$ZELLIJis set andzellij --versionsucceeds, orNone. - Build
AppState. Reads.rustlings-state.txt(handles the "first run" case where it doesn't exist yet), populates theExercisevector with leaked-staticpathand computedcanonical_path, and builds theCmdRunner(which callscargo metadataonce). - Show welcome message if the state file did not exist before this run.
- Dispatch on subcommand:
None→watch::watch(...). Requires a TTY.Run { name }→ optional set-current-by-name, thenrun::run(...).CheckAll→app_state.check_all_exercises(...)and report.Reset { name }→set_current_exercise_by_name, thenreset_current_exercise.Hint { name }→ optional set-current, then print the hint.
2. System Requirements
| Layer | Minimum | Reason |
|---|---|---|
| Rust | 1.88 | Cargo.toml rust-version = "1.88". The release-hook.sh also runs cargo +1.88 dev check --require-solutions so the MSRV is enforced before every release. |
| Cargo | bundled with Rust 1.88 | Subprocess invocations require modern cargo metadata and cargo clippy. |
| Clippy | required | Enforced by init (cargo clippy --version). The Clippy lint set in [workspace.lints.clippy] and the per-exercise [lints.clippy] in the generated Cargo.toml rely on it. |
| Rustfmt 2024 edition | required for solution-shipping community tracks | dev::check runs rustfmt --check --edition 2024 against every solution. |
git |
optional, required for community-track resets | init runs git init; community-track resets run git stash push -- <path>. |
| Working terminal | TTY required for watch and list modes | Non-TTY stdout exits with "Unsupported or missing terminal/TTY". |
Important
The MSRV is enforced via
cargo +1.88 dev checkin the release hook. Any PR that pushes the MSRV upward must updateCargo.toml'srust-versionfield and the release hook script in lockstep, or the next release will silently ship a binary that won't compile on the documented minimum toolchain.
3. Configuration Reference
Rustlings has two configuration surfaces: CLI flags (driving runtime
behavior) and info.toml (defining the exercise set itself).
3.1 CLI flags (parent rustlings command)
| Flag | Type | Default | Description |
|---|---|---|---|
--no-editor |
bool | false |
Disable automatic editor opening; --edit-cmd is ignored when set. |
--edit-cmd <CMD> |
optional string | none | Command to launch your editor on the current exercise. Parsed with shlex. The exercise path is appended as the last argument. The command must not block — use code, subl -n, etc. — never vim. |
--manual-run |
bool | false |
Disable file watching. In watch mode, requires pressing r to rerun. |
--version |
bool | n/a | Print the version (auto-derived from the workspace Cargo.toml via #[command(version)]). |
--help |
bool | n/a | Print top-level help. Per-subcommand --help exists too. |
3.2 CLI subcommands
| Subcommand | Args / flags | Description |
|---|---|---|
init |
none | Bootstrap a new rustlings/ project in the current directory. |
run [NAME] |
NAME: optional exercise name |
Run a single exercise once; if NAME omitted, the next pending exercise. |
check-all |
none | Build/test/clippy/run every exercise in parallel; mark each done or pending. |
reset NAME |
NAME: required exercise name |
Restore a single exercise to its bundled original (or git stash push for community tracks). |
hint [NAME] |
NAME: optional exercise name |
Print the curated hint and exit. |
dev new PATH [--no-git] |
PATH: required, --no-git: bool |
Scaffold a community-exercises project. |
dev check [-r/--require-solutions] |
--require-solutions: bool |
Validate info.toml, Cargo.toml, and (optionally) every solution. |
dev update |
none | Regenerate the bin = […] block from info.toml. |
3.3 The info.toml schema
Every exercise track (the official one and any community fork) is defined by
a single info.toml at the project root. Schema:
# Required. The format version. Currently only `1` is supported.
format_version = 1
# Optional. Shown to users on first run (when no .rustlings-state.txt exists).
welcome_message = """…multi-line text…"""
# Optional. Shown after all exercises are done (above the Ferris ASCII banner).
final_message = """…multi-line text…"""
# Repeat for every exercise.
[[exercises]]
name = "intro1" # required, [a-zA-Z0-9_]+ , <= 32 chars, unique
dir = "00_intro" # optional. If set, file is exercises/<dir>/<name>.rs
test = false # optional, default true. If true, `cargo test` is run.
strict_clippy = false # optional, default false. If true, clippy errors on any warning.
hint = """…multi-line hint shown via the `h` key or `rustlings hint`…"""
skip_check_unsolved = true # optional, default false. See below.
| Field | Type | Default | Required? | Description |
|---|---|---|---|---|
format_version |
u8 | n/a | yes | Must equal CURRENT_FORMAT_VERSION (currently 1). Higher = bail. Lower = bail (in dev check). |
welcome_message |
string | unset | no | Shown on first run only. |
final_message |
string | unset | no | Shown when all exercises are done. |
exercises |
array | n/a | yes | At least one entry, max 999. |
exercises[].name |
&'static str |
n/a | yes | Unique. Pattern [a-zA-Z0-9_]+. Max 32 chars. Must not be empty. Becomes the Cargo bin name and the <name>.rs file stem. |
exercises[].dir |
optional string | none | no | If set, file path is exercises/<dir>/<name>.rs; otherwise exercises/<name>.rs. Same character rules as name, must not be empty if present. |
exercises[].test |
bool | true |
no | If true, cargo test --bin <name> is run after build. Exercise file MUST contain #[test]\n if true; MUST NOT if false (validated by dev check). |
exercises[].strict_clippy |
bool | false |
no | If true, cargo clippy is run with -- -D warnings. Use only for exercises whose entire point is fixing Clippy lints. |
exercises[].hint |
string | n/a | yes | Non-empty after trimming ASCII whitespace. Multi-line strings are common. |
exercises[].skip_check_unsolved |
bool | false |
no | If true, dev check won't fail this exercise for being already-solved. Use for intro-style exercises that ship pre-solved (e.g. intro1). |
Important
The official
info.tomllives atrustlings-macros/info.tomland is embedded at compile time. Editing it requires recompiling therustlingsbinary — installed copies will not see your edits. For runtime-editable tracks, userustlings dev newto scaffold a project with a localinfo.toml, whichInfoFile::parse()prefers over the embedded copy.
3.4 The state file format
.rustlings-state.txt is a simple line-oriented text file maintained by
AppState:
DON'T EDIT THIS FILE!
intro2
intro1
variables1
variables2
- Line 1:
STATE_FILE_HEADER(DON'T EDIT THIS FILE!). - Line 2: blank.
- Line 3: name of the current exercise (always present, even with no done exercises).
- Line 4: blank.
- Lines 5+: names of done exercises, one per line.
Writes are atomic from the program's perspective: rewind() → set_len(0) →
write_all() against the same opened File handle. Concurrent writers are
not supported (Rustlings assumes a single process per project).
3.5 The generated project layout (after rustlings init)
<dir>/
.gitignore ← excludes Cargo.lock, target/, .vscode/ (except extensions.json)
.rustlings-state.txt ← created on first run
.vscode/extensions.json ← {"recommendations":["rust-lang.rust-analyzer"]}
Cargo.toml ← bin = [...] generated from info.toml; pinned edition 2024
rust-analyzer.toml ← check.command = "clippy"; check.extraArgs = ["--profile","test"]; cargo.targetDir = true
exercises/
README.md
00_intro/
README.md
intro1.rs
intro2.rs
01_variables/
…
…
solutions/
README.md
00_intro/intro1.rs ← placeholder, filled when exercise passes
01_variables/variables1.rs
…
If init runs inside a Cargo workspace, the layout is the same except Cargo.toml
is added as a workspace member (workspace.members += ["rustlings"]) and
no git init is run.
3.6 The Cargo.toml bin = […] contract
The generated Cargo.toml (or dev/Cargo.toml in the Rustlings dev tree) has
one [[bin]] entry per exercise in info.toml, in the same order. In a
community track that ships solutions, additional _sol binaries also appear:
bin = [
{ name = "intro1", path = "exercises/00_intro/intro1.rs" },
{ name = "intro1_sol", path = "solutions/00_intro/intro1.rs" },
{ name = "intro2", path = "exercises/00_intro/intro2.rs" },
...
]
src/cargo_toml.rs::bins_start_end_ind finds the literal bin = [ and the
matching ]; append_bins regenerates the contents. dev check diffs the
existing block against the regenerated one and fails if they differ; dev update rewrites the file in place. Never hand-edit bin = […] — use
rustlings dev update.
3.7 Build-time configuration
| File | Purpose |
|---|---|
Cargo.toml (workspace root) |
Workspace members (the rustlings package and rustlings-macros). Excludes tests/test_exercises and dev from the workspace. |
rustlings-macros/Cargo.toml |
[lib] proc-macro = true. Must include info.toml in package.include to embed it in the published crate. |
clippy.toml |
Workspace-wide Clippy denials for inefficient or footgun-y APIs (Stylize, std::thread::spawn, std::process::exit). |
.rumdl.toml |
Disables MD013 (line length) and MD057 (table separator). Per-file ignores for the website's markdown. |
.typos.toml |
Excludes CHANGELOG.md; whitelists earch (used in <s>earch HTML). |
release-hook.sh |
Pre-release gauntlet: typos → cargo clippy --deny warnings → cargo fmt --check → cargo test --workspace → cargo dev check --require-solutions → cargo +1.88 dev check --require-solutions. |
4. Deployment and Distribution
Rustlings is distributed as a single Cargo-installable binary; there is no deployment in the server sense.
4.1 The published artifacts
| Artifact | Where | Purpose |
|---|---|---|
rustlings crate |
crates.io | cargo install rustlings source. Includes only src/, exercises/, solutions/, dev-Cargo.toml, README.md, LICENSE. |
rustlings-macros crate |
crates.io | The proc-macro that embeds files. Includes only src/ and info.toml. Published as a hard dependency at the same workspace version (= 6.5.0). |
rustlings.rust-lang.org website |
GitHub Pages (Zola) | Static site built from website/ (Tailwind via @tailwindcss/cli). Theme: dracula. Search index: disabled. |
4.2 Release procedure
- Bump
versionin[workspace.package](and consequently both crate manifests, since they useversion.workspace = true). - Update
CHANGELOG.md. - Run the release hook locally:
This runs./release-hook.shtypos, Clippy with warnings denied,cargo fmt --check,cargo test --workspace,cargo dev check --require-solutions, and the samedev checkagain under MSRV (cargo +1.88). - Tag the commit (
git tag vX.Y.Z) and push. - Publish
rustlings-macrosfirst (cargo publish -p rustlings-macros), thenrustlings— they share a workspace version so the dependency constraintrustlings-macros = "=X.Y.Z"resolves immediately. - Update
website/and deploy.
Note
Because the workspace pins
rustlings-macros = "=6.5.0"(an=constraint), any version bump torustlingsrequires a matching version bump and simultaneous publish ofrustlings-macros. The release hook implicitly verifies this by runningcargo dev checkagainst the workspace.
4.3 Distribution-channel-specific notes
| Channel | Notes |
|---|---|
cargo install rustlings |
Recommended. Uses the latest published crate. |
cargo install rustlings --locked |
Pins transitive deps to the published Cargo.lock. Use in CI and reproducible scripts. |
| Distro packages | None of the major Linux distros currently ship Rustlings. |
| Source | cargo install --path . from a checkout. Only useful for contributors. |
5. Operations
There is no service to operate, but there are recurring maintenance tasks for the project itself and for community-track maintainers.
5.1 Adding or changing an official exercise
- Edit
rustlings-macros/info.tomlto add the[[exercises]]block. Honor the schema in section 3.3. - Add
exercises/<dir>/<name>.rswith at least:- A
fn main()declaration. - At least one
// TODOcomment. - If
test = true, at least one#[test]\n-annotated function (dev checkenforces this).
- A
- Add
solutions/<dir>/<name>.rsand write the solution.rustfmt-format it (rustfmt --edition 2024 solutions/<dir>/<name>.rs). - Run
cargo dev updateto regeneratedev/Cargo.toml. - Run
cargo dev check --require-solutionsto validate everything. - Run
cargo run -- run <name>to play through the failing state, fix it, confirm it passes. - Commit and open a PR.
Tip
Don't try to author exercises that need network access, big external dependencies, or custom build scripts. The
[lints.rust]block in the generatedCargo.tomlforbidsunsafe_codeandunstable_featuresfor exercise binaries — keep solutions stable-Rust and self-contained.
5.2 Authoring a community-exercise track
rustlings dev new ./my-rustlings
cd my-rustlings
git add .
dev new creates the standard layout, with a single
[[exercises]] placeholder named ??? you must replace. See section 5.1
points 1–6 for adding exercises (substituting cargo run -- with rustlings
since you're using the installed binary).
To validate before publishing:
rustlings dev check --require-solutions
To distribute, push the repo to a Git hosting provider. Users clone it and run
rustlings inside the clone — Rustlings detects the local info.toml and
runs the watch loop against it.
5.3 Maintenance checklist
- [ ] Run
cargo test --workspaceweekly to catch toolchain regressions. - [ ] Run
cargo dev check --require-solutionswhenever a solution or exercise file changes. - [ ] Run
cargo dev updateand commitdev/Cargo.tomlwhenever exercises are added, removed, or renamed. - [ ] Bump MSRV (
Cargo.toml'srust-versionANDrelease-hook.sh's+1.88) together with anything that requires a newer compiler. - [ ] Keep
info.tomlhints up to date when chapters of The Rust Programming Language book are renumbered (most hints link to specific chapters).
6. Performance
6.1 Watch-mode latency
The hot loop is: file save → notify event → 200 ms debounce → cargo build → (cargo test) → cargo clippy → cargo run → render. Latency is dominated by Cargo, which Rustlings cannot meaningfully control. Rustlings' own overhead (notify dispatch, ANSI render, hint render) is sub-millisecond.
The 200 ms debounce in src/watch/notify_event.rs deduplicates rapid saves
(many editors fire two notify events per save, one for the temp file and
one for the rename); it adds at most 200 ms to the perceived round-trip.
Note
Cargo is fastest when the target directory is hot. The first exercise after a clean checkout takes 30–60 s while Cargo compiles the entire dependency tree of the exercise. Subsequent exercises in the same session are fast because most dependencies are cached.
6.2 check-all parallelism
AppState::check_all_exercises uses thread::scope to spawn up to
thread::available_parallelism().map_or(8, |n| n.get()) worker threads, each
running one exercise to completion at a time. A single channel funnels the
results back to the main thread for live progress rendering. If any worker
fails (e.g. EMFILE under heavy parallelism), Rustlings re-runs that exercise
sequentially in a fallback pass.
The default of 8 if available_parallelism errors is a deliberate floor —
parallelism above ~16 saturates Cargo's own internal parallelism and yields
diminishing returns.
6.3 Embedded-file size
Every exercise, solution, and README is include_bytes!()-ed into the
rustlings binary. As of b730882 that's:
- 96 exercise files (~336 KB of source across all of them).
- 96 solution files (similar).
- 24 per-folder READMEs.
- 1
info.toml(~38 KB). - 1
dev-Cargo.toml.
The release binary is in the low single-digit MB range.
7. Security
7.1 Threat model
Rustlings is a developer tool that runs on the developer's own machine,
processes files the user authors, and shells out to cargo/rustc. The
threat model is therefore the developer's untrusted exercise files —
malicious code in an exercise file will execute with the user's privileges
under cargo run.
Warning
When you
git clonea community Rustlings track and runrustlings, you are about to execute Rust code from the repository on your machine, exactly as if you rancargo runon it. Treat community tracks like any other untrusted source code: review the exercises before running.
7.2 Hardening choices in source
| Mechanism | Where | Effect |
|---|---|---|
unsafe_code = "forbid" |
workspace [workspace.lints.rust] and exercise [lints.rust] |
The rustlings binary, and every shipped exercise, refuses to compile any unsafe block. |
unstable_features = "forbid" |
same | No #![feature(...)] allowed; pinning to the documented MSRV. |
disallowed-types/disallowed-methods |
clippy.toml |
Forbids known-inefficient crossterm styling and known-footgun std::thread::spawn, std::process::exit. |
| Subprocess stdin closed | src/cmd.rs::run_cmd |
All cargo subprocesses run with Stdio::null() for stdin to prevent prompts hanging the watcher. |
--no-default-features for crossterm |
Cargo.toml |
Only windows and events features are enabled — keeps the dep surface small. |
| TTY check before raw mode | src/main.rs |
Watch mode aborts with a clean error if stdout isn't a TTY rather than silently corrupting redirected output. |
| Termios save/restore | src/watch.rs::watch (non-Windows) |
The original local_modes are saved before disabling ICANON/ECHO and restored on exit, even if watch mode panics. |
| Editor command parsed with shlex | src/editor.rs |
Avoids ad-hoc string splitting that could mis-handle quoted arguments. |
7.3 Subprocess invariants
Every cargo subprocess Rustlings spawns has:
stdinset toStdio::null().stdoutandstderreither both piped to a buffer (when capturing output) or bothStdio::null()(when discarding).--no-depsand--format-version 1forcargo metadata(deterministic output, no network resolution).
This avoids interactive prompts, prevents partial writes, and guarantees deterministic JSON parsing.
7.4 Reset safety
reset is destructive. For official Rustlings, Rustlings overwrites the file
with the embedded original (no backup). For community tracks, Rustlings runs
git stash push -- <path> so your work is recoverable from the Git stash.
Caution
If a user runs
rustlings reset <name>outside any Git working tree on a community track, the underlyinggit stash pushwill fail and Rustlings aborts before touching the file. In an official Rustlings track, however, the reset proceeds without any backup, so unsaved work in the exercise file is gone permanently. Always commit or back up your edits before invoking reset on the official track.
8. Developer Internals
8.1 Repository structure
rustlings/
├── Cargo.toml # workspace + rustlings package
├── Cargo.lock
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE # MIT
├── README.md
├── build.rs # Windows-only dev-Cargo.toml copy
├── clippy.toml # workspace-wide Clippy denials
├── dev-Cargo.toml # symlink/copy of dev/Cargo.toml (for crates.io packaging)
├── release-hook.sh
├── src/
│ ├── main.rs # CLI dispatcher
│ ├── cli.rs # clap definitions
│ ├── app_state.rs # AppState + ExercisesProgress + state-file logic
│ ├── exercise.rs # Exercise + RunnableExercise trait
│ ├── info_file.rs # ExerciseInfo + InfoFile + parse()
│ ├── embedded.rs # EMBEDDED_FILES wrapper
│ ├── cargo_toml.rs # `bin = […]` regen
│ ├── cmd.rs # CmdRunner subprocess wrapper
│ ├── editor.rs # Editor (Cmd, Zellij)
│ ├── editor/zellij.rs # Zellij action edit / pane close helpers
│ ├── init.rs # `rustlings init`
│ ├── run.rs # `rustlings run`
│ ├── list.rs # list-mode entry + key handling
│ ├── list/scroll_state.rs # scroll-window math
│ ├── list/state.rs # ListState (filter, search, draw)
│ ├── term.rs # ANSI helpers, progress bar, file-link emitter
│ ├── watch.rs # watch-mode entry, termios save/restore
│ ├── watch/state.rs # WatchState (run, render, hint, reset, check-all)
│ ├── watch/notify_event.rs # NotifyEventHandler (200 ms debounce, exercise-name match)
│ ├── watch/terminal_event.rs # terminal_event_handler (key→InputEvent)
│ ├── dev.rs # DevCommand subcommand dispatch
│ ├── dev/check.rs # `rustlings dev check`
│ ├── dev/new.rs # `rustlings dev new`
│ └── dev/update.rs # `rustlings dev update`
├── rustlings-macros/
│ ├── Cargo.toml
│ ├── info.toml # the official exercise registry (the IP)
│ └── src/lib.rs # `include_files!()` proc-macro
├── exercises/ # 96 exercise files in 24 dirs + per-dir README
├── solutions/ # 96 solutions matching exercises/ structure
├── tests/
│ ├── integration_tests.rs # CLI-as-subprocess tests
│ └── test_exercises/ # synthetic mini-track for testing dev check
├── website/ # Zola static site → rustlings.rust-lang.org
└── dev/ # dev-only Cargo project (`cargo dev …`)
└── Cargo.toml # generated bin = […] for in-tree dev runs
8.2 AppState lifecycle
AppState is the single owning struct for runtime state. It is constructed
in main() and passed &mut to every subcommand handler.
Construction (AppState::new):
- Build a
CmdRunner(callscargo metadataonce). OpenOptions::new().create(true).read(true).write(true).truncate(false)on.rustlings-state.txt.term::canonicalize("exercises")— the canonical absolute path used to build clickable terminal file links.- Build the
exercises: Vec<Exercise>frominfo_file.exercises:- Compute each exercise's
path(exercises/<dir>/<name>.rs) andcanonical_path(canonicalized, with the Windows\\?\prefix stripped). - Leak the
pathString to&'static strso it can be reused in editor subprocess args without lifetime juggling.
- Compute each exercise's
- Read the existing state file, parse current-exercise + done-set lines.
If the file is empty or malformed, return
StateFileStatus::NotReadsomainknows to show the welcome message.
Mutation methods:
set_current_exercise_ind(ind)/set_current_exercise_by_name(name)— bounds-check, set the index, write the state file.set_status(ind, done)— flipExercise.doneand increment/decrementn_donewithout writing.set_pending(ind)— convenience:set_status(ind, false)then write if changed.reset_current_exercise()/reset_exercise_by_ind(ind)— set pending, then dump the file (official: fromEMBEDDED_FILES, community:git stash push -- <path>).check_all_exercises(stdout)— see section 6.2.done_current_exercise::<CLEAR_BEFORE_FINAL_CHECK>(stdout)— mark current done, advance to next pending, or run a final check-all when nothing pending remains. The const-generic flag controls whether to clear the screen before the final check (used by watch mode but not byrun).
8.3 The RunnableExercise trait
pub trait RunnableExercise {
fn name(&self) -> &str;
fn dir(&self) -> Option<&str>;
fn strict_clippy(&self) -> bool;
fn test(&self) -> bool;
fn run<const FORCE_STRICT_CLIPPY: bool>(
&self,
bin_name: &str,
output: Option<&mut Vec<u8>>,
cmd_runner: &CmdRunner,
) -> Result<bool> { ... }
fn run_exercise(&self, output, cmd_runner) -> Result<bool> { ... }
fn run_solution(&self, output, cmd_runner) -> Result<bool> { ... }
fn sol_path(&self) -> String { ... }
}
run_exercise calls the generic run::<false>(self.name(), …). run_solution
appends _sol to the bin name and calls run::<true>(…) so solutions are
always linted at the strict level.
The run method's pipeline:
cargo build -q --bin <bin>(with optional--manifest-path dev/Cargo.tomlin debug builds for the in-tree development workflow).- If build failed, return
Ok(false)early. - If
testis true:cargo test … --bin <bin>(with-- --color always --format prettyif output is captured). - If test failed, run the bin to dump output, return
Ok(false). cargo clippy --profile test --bin <bin>(with-- -D warningsifstrict_clippyor the const-generic flag is set).- Run the bin:
cmd_runner.run_debug_bin(bin_name, …)— directly executes<target_dir>/debug/<bin>rather than going throughcargo run, to avoid re-entering Cargo. - Return
Ok(clippy_success && run_success).
Note
Steps 1, 3, and 5 each clear the output buffer between phases so Rustlings can show only the most relevant stage's output to the user — there's no reason to show stale
cargo buildoutput oncecargo clippyhas spoken.
8.4 The EMBEDDED_FILES proc-macro expansion
rustlings-macros/src/lib.rs::include_files!() runs at compile time:
- Reads
rustlings-macros/info.tomlviainclude_bytes!()(with\rstripped for Windows). - Parses with
toml::de::from_str::<InfoFile>to get the exercise list. - Builds three vectors:
exercise_files:format!("../exercises/{dir}/{name}.rs").solution_files:format!("../solutions/{dir}/{name}.rs").dirs: deduplicated directory names, with each exercise'sdir_indpointing into this vector (avoids storing the directory string per exercise).
- Builds
readmesfrom each unique dir:format!("../exercises/{dir}/README.md"). - Emits a
quote!macro expansion that builds anEmbeddedFiles { … }literal — every path becomes a compile-timeinclude_bytes!()call, so the output is a static byte-array reference.
The expansion is the single static EMBEDDED_FILES: EmbeddedFiles = … in
src/embedded.rs.
8.5 The watch-mode state machine
src/watch/state.rs::WatchState holds app_state, output, show_hint,
done_status, manual_run, term_width, and a sender channel for
unpausing the terminal-event listener.
The main run_watch function in src/watch.rs:
- Spawns a
notifywatcher rooted atexercises/, withwith_follow_symlinks(false)and a 1 s poll interval as fallback. - Spawns the
NotifyEventHandlerworker with a 200 ms debounce timer. - Spawns the
terminal_event_handlerthread (reads keys/resizes viacrossterm::event::read). - Builds a
WatchStateand immediately callsrun_current_exercise. - Enters a
recv()loop on the watch event channel and dispatches:Input::Next→next_exercise(no-op if not done).Input::Run→run_current_exercise(manual mode only).Input::Hint→show_hint.Input::List→ returnsWatchExit::List(the list/watch outer loop handles entering/leaving the list view cleanly).Input::CheckAll→check_all_exercises.Input::Reset→reset_exercise(interactive y/n prompt; pauses the terminal-event thread until the prompt is dismissed).Input::Quit→ break.FileChange { exercise_ind }→ ignored if it isn't the current exercise; otherwise re-run.TerminalResize { width }→ re-render with the new width.- errors → propagate.
Input handling is paused (InputPauseGuard/EXERCISE_RUNNING atomic) while
exercises are running, so keys pressed during the build don't get queued and
processed afterward.
8.6 List-view internals
The ListState in src/list/state.rs keeps a ScrollState, an optional
Filter (None/Done/Pending), and a search_query: String. Drawing
uses crossterm synchronized updates (BeginSynchronizedUpdate/
EndSynchronizedUpdate) to avoid flicker. The ScrollState (in
scroll_state.rs) handles selection-window math: it keeps a scroll padding
of max_n_rows_to_display / 4 (capped at max_scroll_padding) so the
selection rarely sits at the very top or bottom of the visible window.
apply_search_query is called on every search-mode keystroke; it filters
the in-memory exercise list and rebuilds the visible rows.
8.7 The dev check rule set
src/dev/check.rs::check is the validator for community tracks. It runs in
this order:
info_file.exercises.len() <= MAX_N_EXERCISES (999)or bail.check_cargo_toml: thebin = […]regenerated frominfo.tomlbyte-for- byte equals the existingbin = […]. If not, suggestrustlings dev update.check_exercises(viacheck_info_file_exercises): per-exercise:namenon-empty and<= 32chars.namematches[a-zA-Z0-9_].- If
diris present: same character set, non-empty. hintnon-empty aftertrim_ascii.nameis unique (HashSet insertion).- File at
path()opens for reading. - File contains
fn main(). - File contains
// TODO. - File contains
#[test]\nif and only iftest == true.
check_unexpected_files("exercises", …): every file inexercises/is either aREADME.mdor matches aninfo.tomlentry. Only one level of directory nesting is allowed.- In parallel with 3+4,
check_exercises_unsolvedactually runs every exercise (skipping those withskip_check_unsolved = true) and bails if any exercise passes — the bundled exercise file shouldn't already be solved. - If
--require-solutions:check_solutionsruns every solution, requires presence (errors if missing), runscargo build/test/clippy/runon it, and must pass with strict Clippy.rustfmt --check --edition 2024is run against every solution path.check_unexpected_files("solutions", …)validates layout.
Important
The
dev checkrule "exercise must contain// TODO" is a strong UX contract: it guarantees that every exercise file has at least one obvious hand-hold for the learner. If you intentionally don't want a TODO comment (e.g. an intro file that's pre-solved), setskip_check_unsolved = trueAND also keep at least one TODO comment in the file —dev checkdoes not currently exempt TODO checking even whenskip_check_unsolvedis set.
8.8 Testing strategy
| Layer | Where | What it covers |
|---|---|---|
| Unit | inline #[cfg(test)] mod tests blocks in app_state.rs, cargo_toml.rs, cmd.rs, embedded.rs |
The next_pending_exercise_ind algorithm, bin = […] parser/regenerator, run_cmd echoing through a pipe, the embedded-files round-trip vs. info.toml. |
| Integration | tests/integration_tests.rs plus the synthetic track in tests/test_exercises/ |
Run the binary as a subprocess against a known-good and known-bad exercise set, assert the right exit codes and outputs. |
| Format check | cargo fmt --check (release hook) |
Style consistency. |
| Lint | cargo clippy --deny warnings (release hook) |
Catches lint regressions. |
| MSRV | cargo +1.88 dev check --require-solutions (release hook) |
Confirms the binary still builds and the whole exercise set still passes on the MSRV toolchain. |
There are 26 classes (structs/enums) and 86 named functions/methods across
the binary. Coverage is concentrated in app_state.rs (state-file invariants)
and cargo_toml.rs (the bin = […] regen); the watch-mode and editor logic
are exercised by integration tests rather than units, since they require a
real TTY and a real notify backend.
8.9 Code conventions
- No
unsafe, ever. Workspace-wide forbid. - No unstable features. Workspace-wide forbid.
anyhow::bail!/Contextonly, neverpanic!()for user-facing errors. Panics are reserved for invariants the type system can't express.- Static error strings are pulled out as
const _ERR: &str = "…"at the bottom of the module they're raised in, mirroring the Rust standard library's style. leak()is used judiciously to convertStringto&'static strwhen the data lives until program exit (exercise paths, parsedinfo.tomlcontents). The trade-off is intentional: avoiding lifetime parameters onEditor::openis worth a few KB of leaked allocations that the OS reclaims on exit.thread::Builder::spawnnotthread::spawn. The Clippydisallowed- methodsconfig forces every thread spawn to handle the spawn error explicitly, which catches OOM and resource-exhaustion at thread-creation time rather than after the fact.process::ExitCodenotprocess::exit(). All paths return throughmain -> Result<ExitCode>so destructors run.
8.10 Potential improvements
The community has flagged several enhancements via GitHub Issues; the team tracks them publicly:
| Issue | Topic | Notes |
|---|---|---|
| #1501 | Hints with offline documentation suggestions | Open. Would require a new info.toml field. |
| #1764 | More quizzes (quiz4+) | Open. Material decision pending. |
| #2275 | Offline mode | Open. The runtime is already offline once installed; the hints only contain external URLs as references. |
| #2272 | Clearer "how to start" docs in the README | Open. Resolved largely in CONTRIBUTING.md, but the README itself still leans on the website. |
| #2326 | quiz1 wording |
Open. Cosmetic. |
| #2340 | Reorder exercises to match The Book | Open. Would be a major UX/info.toml rewrite. |
| #2379 | Potential overflow in functions5 exercise |
Open suggestion. The exercise tests the operator-as-expression-vs-statement issue, not arithmetic correctness, so the type the user picks doesn't actually overflow under the test. |
| #887 | More Option exercises (ok_or, and_then) |
Open. |
| #2230 | Confusing comment in 09_strings/strings2 |
Open. |
| #2307 | 08_enums/enum2 Messages-array vs. variables |
Open design discussion. |
| #2338 | More links in 09_strings/strings3 README |
Open. |
| #2339 | Better test for 10_modules/modules1 |
Open. |
| #2228 | structs2 line 35 panic (closed) |
Resolved upstream. |
| #2164 | Open current exercise in $EDITOR (closed) |
Resolved by the --edit-cmd flag and VS Code/Zellij autodetection. |
8.11 Contributing
The full guide lives in CONTRIBUTING.md. Workflow summary:
- Open an issue first for non-trivial changes — the team prefers design- level alignment before code review.
- Branch from
main, make changes. - Run
./release-hook.shlocally to mirror CI. - PR. CI runs the same gauntlet on Linux/macOS/Windows.
- Squash-merge after review.
Tip
The
release-hook.shscript is the canonical "did I break anything" check. If it passes locally, your PR will almost certainly pass CI. If you want to run only a subset,cargo clippy -- --deny warningsandcargo dev check --require-solutionstogether cover the bulk of the rulesdev checkenforces.
9. Integration Points
Rustlings is a leaf-node tool, but it does interface with several external systems:
| System | Direction | How |
|---|---|---|
| Cargo | outbound | cargo metadata once at startup; per-exercise cargo build/test/clippy. |
rustfmt |
outbound | dev check --require-solutions formats every solution. |
git |
outbound | init runs git init. Community-track reset runs git stash push -- <path>. |
| File-system watcher | inbound | notify::RecommendedWatcher on exercises/ (recursive). |
| Terminal | bidirectional | crossterm for input/output. rustix::termios (non-Windows) to disable canonical mode and echo. |
| VS Code | outbound | code (or codium) executable for the Open editor flow. Editor detection via TERM_PROGRAM=vscode. |
| Zellij | outbound | zellij action edit <path> to open exercises as panes. Pane-id parsing in src/editor/zellij.rs. |
cargo install registry |
inbound | crates.io publication. |
9.1 Embedding Rustlings into a curriculum
A common pattern: an organization wants a private exercise set that runs in the student's normal Rustlings install. Two approaches:
- Community-track repo. Use
rustlings dev new, push the resulting project to a private Git repo, ask students to clone and run. Maintains one Rustlings install across all of them; you ship only the exercise files. - Forked binary. Fork
rust-lang/rustlings, replacerustlings-macros/info.tomland theexercises//solutions/trees, bump the binary name, and publish to a private cargo registry. Heavier maintenance burden, but you control the binary's behavior.
Approach 1 is strongly recommended unless you need to change the runtime itself.
9.2 CI integration
A CI job for a community track typically runs:
cargo install rustlings --locked
rustlings dev check --require-solutions
This validates info.toml, the Cargo.toml bin block, every exercise
file's conventions, and every solution. A failing exit code from dev check
means at least one rule is violated; the error message identifies the file
and rule.
For curriculum-level CI (each student's progress), rustlings check-all
exits 1 if anything is pending, which is composable with standard CI gates.