Jesse A. Green / notebook

Rustlings Administrator and Developer Guide

Generated April 2026 from rust-lang/rustlings at b730882

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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.toml registry, and per-folder READMEs are all embedded into the binary at compile time by the rustlings_macros::include_files!() proc-macro. That's why the end-user install flow does not need to clone the repository — rustlings init can dump the entire exercises/ and solutions/ tree from the binary itself. See rustlings-macros/src/lib.rs and src/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:

  1. Parse args with clap (Args::parse() returning the Args struct).
  2. Block the legacy workflow. In a release build, if a dev/rustlings-repo.txt marker file is present in the current directory, bail with the "old method before version 6" error.
  3. Priority subcommands. Init and Dev(...) short-circuit through their handlers in init::init() and DevCommand::run() respectively, then exit SUCCESS.
  4. Verify location. If exercises/ doesn't exist, print the PRE_INIT_MSG ASCII banner with usage instructions and exit FAILURE.
  5. Parse info.toml. InfoFile::parse() first tries to read info.toml from the current dir (community track), falling back to the embedded info.toml baked in by rustlings_macros::include_files!().
  6. Reject newer format_version. Bail with FORMAT_VERSION_HIGHER_ERR if info_file.format_version > CURRENT_FORMAT_VERSION (1).
  7. Detect editor. Editor::new(args.edit_cmd, vs_code_term). Returns Some(Editor::Cmd(...)) for VS Code or --edit-cmd, Some(Editor::Zellij(_)) if $ZELLIJ is set and zellij --version succeeds, or None.
  8. Build AppState. Reads .rustlings-state.txt (handles the "first run" case where it doesn't exist yet), populates the Exercise vector with leaked-static path and computed canonical_path, and builds the CmdRunner (which calls cargo metadata once).
  9. Show welcome message if the state file did not exist before this run.
  10. Dispatch on subcommand:
    • None → watch::watch(...). Requires a TTY.
    • Run { name } → optional set-current-by-name, then run::run(...).
    • CheckAll → app_state.check_all_exercises(...) and report.
    • Reset { name } → set_current_exercise_by_name, then reset_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 check in the release hook. Any PR that pushes the MSRV upward must update Cargo.toml's rust-version field 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.toml lives at rustlings-macros/info.toml and is embedded at compile time. Editing it requires recompiling the rustlings binary — installed copies will not see your edits. For runtime-editable tracks, use rustlings dev new to scaffold a project with a local info.toml, which InfoFile::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

  1. Bump version in [workspace.package] (and consequently both crate manifests, since they use version.workspace = true).
  2. Update CHANGELOG.md.
  3. Run the release hook locally:
    ./release-hook.sh
    
    This runs typos, Clippy with warnings denied, cargo fmt --check, cargo test --workspace, cargo dev check --require-solutions, and the same dev check again under MSRV (cargo +1.88).
  4. Tag the commit (git tag vX.Y.Z) and push.
  5. Publish rustlings-macros first (cargo publish -p rustlings-macros), then rustlings — they share a workspace version so the dependency constraint rustlings-macros = "=X.Y.Z" resolves immediately.
  6. Update website/ and deploy.

Note

Because the workspace pins rustlings-macros = "=6.5.0" (an = constraint), any version bump to rustlings requires a matching version bump and simultaneous publish of rustlings-macros. The release hook implicitly verifies this by running cargo dev check against 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

  1. Edit rustlings-macros/info.toml to add the [[exercises]] block. Honor the schema in section 3.3.
  2. Add exercises/<dir>/<name>.rs with at least:
    • A fn main() declaration.
    • At least one // TODO comment.
    • If test = true, at least one #[test]\n-annotated function (dev check enforces this).
  3. Add solutions/<dir>/<name>.rs and write the solution. rustfmt-format it (rustfmt --edition 2024 solutions/<dir>/<name>.rs).
  4. Run cargo dev update to regenerate dev/Cargo.toml.
  5. Run cargo dev check --require-solutions to validate everything.
  6. Run cargo run -- run <name> to play through the failing state, fix it, confirm it passes.
  7. 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 generated Cargo.toml forbids unsafe_code and unstable_features for 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 --workspace weekly to catch toolchain regressions.
  • [ ] Run cargo dev check --require-solutions whenever a solution or exercise file changes.
  • [ ] Run cargo dev update and commit dev/Cargo.toml whenever exercises are added, removed, or renamed.
  • [ ] Bump MSRV (Cargo.toml's rust-version AND release-hook.sh's +1.88) together with anything that requires a newer compiler.
  • [ ] Keep info.toml hints 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 clone a community Rustlings track and run rustlings, you are about to execute Rust code from the repository on your machine, exactly as if you ran cargo run on 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:

  • stdin set to Stdio::null().
  • stdout and stderr either both piped to a buffer (when capturing output) or both Stdio::null() (when discarding).
  • --no-deps and --format-version 1 for cargo 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 underlying git stash push will 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):

  1. Build a CmdRunner (calls cargo metadata once).
  2. OpenOptions::new().create(true).read(true).write(true).truncate(false) on .rustlings-state.txt.
  3. term::canonicalize("exercises") — the canonical absolute path used to build clickable terminal file links.
  4. Build the exercises: Vec<Exercise> from info_file.exercises:
    • Compute each exercise's path (exercises/<dir>/<name>.rs) and canonical_path (canonicalized, with the Windows \\?\ prefix stripped).
    • Leak the path String to &'static str so it can be reused in editor subprocess args without lifetime juggling.
  5. Read the existing state file, parse current-exercise + done-set lines. If the file is empty or malformed, return StateFileStatus::NotRead so main knows 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) — flip Exercise.done and increment/decrement n_done without 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: from EMBEDDED_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 by run).

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:

  1. cargo build -q --bin <bin> (with optional --manifest-path dev/Cargo.toml in debug builds for the in-tree development workflow).
  2. If build failed, return Ok(false) early.
  3. If test is true: cargo test … --bin <bin> (with -- --color always --format pretty if output is captured).
  4. If test failed, run the bin to dump output, return Ok(false).
  5. cargo clippy --profile test --bin <bin> (with -- -D warnings if strict_clippy or the const-generic flag is set).
  6. Run the bin: cmd_runner.run_debug_bin(bin_name, …) — directly executes <target_dir>/debug/<bin> rather than going through cargo run, to avoid re-entering Cargo.
  7. 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 build output once cargo clippy has spoken.

8.4 The EMBEDDED_FILES proc-macro expansion

rustlings-macros/src/lib.rs::include_files!() runs at compile time:

  1. Reads rustlings-macros/info.toml via include_bytes!() (with \r stripped for Windows).
  2. Parses with toml::de::from_str::<InfoFile> to get the exercise list.
  3. Builds three vectors:
    • exercise_files: format!("../exercises/{dir}/{name}.rs").
    • solution_files: format!("../solutions/{dir}/{name}.rs").
    • dirs: deduplicated directory names, with each exercise's dir_ind pointing into this vector (avoids storing the directory string per exercise).
  4. Builds readmes from each unique dir: format!("../exercises/{dir}/README.md").
  5. Emits a quote! macro expansion that builds an EmbeddedFiles { … } literal — every path becomes a compile-time include_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:

  1. Spawns a notify watcher rooted at exercises/, with with_follow_symlinks(false) and a 1 s poll interval as fallback.
  2. Spawns the NotifyEventHandler worker with a 200 ms debounce timer.
  3. Spawns the terminal_event_handler thread (reads keys/resizes via crossterm::event::read).
  4. Builds a WatchState and immediately calls run_current_exercise.
  5. 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 → returns WatchExit::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:

  1. info_file.exercises.len() <= MAX_N_EXERCISES (999) or bail.
  2. check_cargo_toml: the bin = […] regenerated from info.toml byte-for- byte equals the existing bin = […]. If not, suggest rustlings dev update.
  3. check_exercises (via check_info_file_exercises): per-exercise:
    • name non-empty and <= 32 chars.
    • name matches [a-zA-Z0-9_].
    • If dir is present: same character set, non-empty.
    • hint non-empty after trim_ascii.
    • name is unique (HashSet insertion).
    • File at path() opens for reading.
    • File contains fn main().
    • File contains // TODO.
    • File contains #[test]\n if and only if test == true.
  4. check_unexpected_files("exercises", …): every file in exercises/ is either a README.md or matches an info.toml entry. Only one level of directory nesting is allowed.
  5. In parallel with 3+4, check_exercises_unsolved actually runs every exercise (skipping those with skip_check_unsolved = true) and bails if any exercise passes — the bundled exercise file shouldn't already be solved.
  6. If --require-solutions:
    • check_solutions runs every solution, requires presence (errors if missing), runs cargo build/test/clippy/run on it, and must pass with strict Clippy.
    • rustfmt --check --edition 2024 is run against every solution path.
    • check_unexpected_files("solutions", …) validates layout.

Important

The dev check rule "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), set skip_check_unsolved = true AND also keep at least one TODO comment in the file — dev check does not currently exempt TODO checking even when skip_check_unsolved is 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!/Context only, never panic!() 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 convert String to &'static str when the data lives until program exit (exercise paths, parsed info.toml contents). The trade-off is intentional: avoiding lifetime parameters on Editor::open is worth a few KB of leaked allocations that the OS reclaims on exit.
  • thread::Builder::spawn not thread::spawn. The Clippy disallowed- methods config 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::ExitCode not process::exit(). All paths return through main -> 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:

  1. Open an issue first for non-trivial changes — the team prefers design- level alignment before code review.
  2. Branch from main, make changes.
  3. Run ./release-hook.sh locally to mirror CI.
  4. PR. CI runs the same gauntlet on Linux/macOS/Windows.
  5. Squash-merge after review.

Tip

The release-hook.sh script 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 warnings and cargo dev check --require-solutions together cover the bulk of the rules dev check enforces.

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:

  1. 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.
  2. Forked binary. Fork rust-lang/rustlings, replace rustlings-macros/info.toml and the exercises//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.