all posts

Rust: First Steps. The Todo That Forgets Everything

Everybody has heard the same three things about Rust. Fast as C. Safe like nothing else. Impossible to learn.

The first two are true. The third one isn’t, with a caveat: Rust isn’t hard. It’s uncooperative. Those are different things, and the difference shows up in the first hour.

No evangelism here, and no comparison table against Go. Here’s what there is instead: we install the toolchain, make a project, and write a terminal todo list in it. And on the way we step on every rake a person steps on in their first two days. I’m not going to walk around them on purpose — half the point is watching what the compiler says when you’re wrong. It says it in surprisingly human language.

Zero dependencies. Standard library only. Yes, in a real project you’d hang clap and serde on a CLI like this and write the same thing three times shorter. But then we’d be getting to know two crates rather than Rust: the reader would copy #[derive(Parser)] and not understand a line of it. So, by hand.

At the end there’s a working program that loses everything on exit. In part two we’ll teach it to remember.

Installing it

One way. It’s also the official one, and it’s also the right one:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

That’s rustup — a toolchain manager (roughly what NVM and RVM are for node and ruby respectively). Don’t install Rust from your system package manager, seriously. Six months from now you’ll need a different compiler version or one extra target, and you’ll end up at rustup anyway. Only now with a conflict in your PATH.

Checking:

rustc --version
cargo --version
rustc 1.98.1 (48a229cea 2026-09-01)
cargo 1.98.1 (797e8a9bc 2026-08-05)

rustc is the compiler, and you’ll almost never call it by hand. cargo is everything else: builds, dependencies, tests, publishing. The rest of this article is only about that one.

For an editor — VS Code with rust-analyzer. Or any other editor with the same rust-analyzer. This isn’t a “nice to have”, it’s mandatory: types in Rust are inferred almost everywhere, and without the hints you literally cannot see what’s in a variable. More than half the meaning of a line like that is what isn’t written in it.

The first project

cargo new todo
cd todo

Inside:

todo/
  Cargo.toml
  .gitignore
  src/
    main.rs

And a .git beside it: cargo new initializes a repository right away, as long as you aren’t inside another one. Nice touch.

Cargo.toml is the manifest:

[package]
name = "todo"
version = "0.1.0"
edition = "2024"

[dependencies]

edition is an important thing worth saying up front, because it throws people. An edition is not a version of the language. It’s a set of syntactic conventions: 2015, 2018, 2021, 2024. A 2026 compiler builds all four, and a crate on edition 2015 links against a crate on 2024 without complaint. That’s how Rust can change awkward things without breaking the ecosystem: old code keeps compiling with the new compiler. What matters to you out of all this is exactly one thing: when you google an answer on Stack Overflow from 2019, look at the date.

src/main.rs:

fn main() {
  println!("Hello, world!");
}
cargo run
   Compiling todo v0.1.0 (/tmp/todo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.49s
     Running `target/debug/todo`
Hello, world!

Two things about that line.

println! with an exclamation mark is a macro, not a function. An exclamation mark after a name in Rust means “this is a macro” (on its own, ! is also negation, and you’ll see it a few sections from now). It’s needed here because println! checks the format string at compile time: write println!("{} {}", x) and forget the second argument, and the program won’t build. An ordinary function can’t do that, hence a macro.

And cargo run built a debug build. It’s slow — sometimes tens of times slower than the release one. Don’t panic about performance until cargo build --release; those are two different programs as far as speed goes.

A task

Start with what a task is. Some text and a checkbox:

struct Task {
  title: String,
  done: bool,
}

struct is just a struct, nothing unusual. The unusual part starts at String.

Rust has two string types, and that’s the first thing everybody trips over. String is a string your variable owns: it lives on the heap, you can change it, you can grow it. &str is a view onto somebody else’s bytes: a slice, a reference, a pointer with a length. The literal "buy milk" in your code is a &str, baked straight into the binary and alive for the whole program.

Bytes, note, and not characters — and with half of this blog in Russian, that isn’t pedantry here. "купить молоко" is thirteen characters and twenty-five bytes, len() returns twenty-five, and you simply won’t be allowed to take s[0]: indexing a string is forbidden in Rust, because there’s no honest answer to it.

Which is why this won’t build:

let task = Task { title: "buy milk", done: false };
error[E0308]: mismatched types
 --> src/main.rs:7:28
  |
7 |   let task = Task { title: "buy milk", done: false };
  |                            ^^^^^^^^^^ expected `String`, found `&str`
  |
help: try using a conversion method
  |
7 |   let task = Task { title: "buy milk".to_string(), done: false };
  |                                      ++++++++++++

Look at that help. The compiler didn’t just say “the types don’t match” — it offered a concrete edit, with plus signs under the exact spot to put it. That’s a signature trait of Rust generally: the errors here are written as though by somebody who tripped over the same thing themselves. Get in the habit of reading them whole instead of grabbing the first line.

Change it to String::from("buy milk") (or .to_string(), no difference) and move on.

First thing you trip over: the value moves out

Now the list. We put the task into a vector:

fn main() {
  let task = Task {
    title: String::from("buy milk"),
    done: false,
  };

  let mut tasks = Vec::new();
  tasks.push(task);

  println!("first task: {}", task.title);
  println!("tasks in total: {}", tasks.len());
}

Put it in the vector. Printed it. In any other language this works.

error[E0382]: borrow of moved value: `task`
  --> src/main.rs:15:30
   |
 7 |   let task = Task {
   |       ---- move occurs because `task` has type `Task`, which does not implement the `Copy` trait
...
13 |   tasks.push(task);
   |              ---- value moved here
14 |
15 |   println!("first task: {}", task.title);
   |                              ^^^^^^^^^^ value borrowed here after move
   |
note: if `Task` implemented `Clone`, you could clone the value

There it is.

This is the ownership that Rust exists for in the first place. Worth stopping here, because everything after this revolves around it.

One rule: every value has exactly one owner. The owner goes out of scope, the value is freed. The compiler knows which line to insert that freeing on, and inserts it.

No garbage collector. No free() by hand. No double free and not one touch of freed memory.

Leaks, incidentally, are not on that list — and that’s a position the language takes, not an oversight. You can forget to free memory in safe Rust (mem::forget, Box::leak, a cycle of Rc), and it doesn’t count as a safety hole: a leak drops nobody, it just takes up room.

Everything else follows from that rule. tasks.push(task) doesn’t copy the task into the vector — it hands over ownership. The vector owns that string now, and the variable task no longer points at anything. The compiler didn’t “delete” it; it marked it as moved out and forbade you to touch it.

tasks.push(task)after the push — emptytaskowns the stringtasks[0]owns the stringtaskhands off
Ownership moves into the vector

Three ways to live with this:

  1. Don’t touch it after it moves. Read out of the vector: tasks[0].title. That’s usually enough, and it’s the most correct option.
  2. Lend it instead of giving it away. That’s below, and it’s &.
  3. Copy it. #[derive(Clone)] and tasks.push(task.clone()). It works, but if you reach for .clone() every time the compiler complains, you aren’t writing Rust — you’re writing “Rust with a carelessness tax”. Sometimes you really do need to clone. In your first weeks, almost never.

Take the first one:

println!("first task: {}", tasks[0].title);

Builds.

And by the way, let mut tasks — did you notice the mut? Everything in Rust is immutable by default: let x = 5 can’t be changed, let mut x = 5 can. This isn’t a constant: const in Rust is its own thing, and a let without mut is an ordinary variable that’s simply forbidden a second assignment. For the first week you’ll forget mut on every other line. Then you get used to it. And later still you notice how little of it there is in other people’s code, which says a lot on its own.

Debug, or how to print a struct

You want to see what’s inside. println!("{}", task) won’t build: {} is for types that know how to show themselves to a user, and a struct doesn’t know that by default. What there is instead is {:?}, for debugging:

#[derive(Debug)]
struct Task {
  title: String,
  done: bool,
}
println!("{:?}", tasks[0]);
println!("{:#?}", tasks[0]);
Task { title: "buy milk", done: false }
Task {
    title: "buy milk",
    done: false,
}

#[derive(...)] is what Rust has instead of inheritance. A trait (Debug) describes behavior, and derive asks the compiler to write the implementation for you. Debug, Clone, PartialEq, Default — most of your day-to-day life will consist of that one line. {:#?} is the same thing stacked vertically; on two fields there isn’t much in it, but on a nested config it’s a rescue.

You might, by the way, have noticed a warning in the output:

warning: field `done` is never read

The compiler is grumbling about an unused field. It grumbles a lot in general, and almost always with cause. Don’t get used to yellow text in your output — you stop seeing it after a while.

The list, and methods

Printing one task at a time is dull; we need a list. And a first impl while we’re at it:

impl Task {
  fn new(title: String) -> Task {
    Task { title, done: false }
  }

  fn mark(&self) -> char {
    if self.done { 'x' } else { ' ' }
  }
}

impl Task is a block of the type’s methods, separate from where its fields are declared. Unfamiliar after languages where everything sits inside one set of braces, but convenient: a type can have many trait implementations, and all of them are separate blocks.

fn new(title: String) -> Task is an associated function — there’s no self in it. It’s not a constructor in any language-level sense; new is just a naming convention. You call it through ::Task::new(...).

fn mark(&self) -> char is a method, self is there. You call it through a dot — task.mark().

Task { title, done: false } is shorthand: when the variable is named the same as the field, you don’t write title: title.

And if self.done { 'x' } else { ' ' } is not a statement but an expression: it returns a value. Almost everything in Rust turns out to be an expression — if, match, a block in braces. Which is also why nobody writes return at the end of a function: the last expression without a semicolon is the result. A semicolon, conversely, throws the value away, and that’s a regular source of “expected char, found ()”.

Now the output:

fn print_list(tasks: &[Task]) {
  if tasks.is_empty() {
    println!("  (empty)");
    return;
  }

  for (i, task) in tasks.iter().enumerate() {
    println!("{:>3}. [{}] {}", i + 1, task.mark(), task.title);
  }
}

&[Task] instead of &Vec<Task> is exactly option 2 from the list above, “lend it”. The function doesn’t take the vector; it gets a slice — a window onto somebody else’s data. The owner stays outside, and it can go on being used after the call. Why a slice rather than &Vec: a slice accepts a vector, an array, and a piece of a vector, and &Vec accepts only a vector. The habit of taking &[T] in arguments arrives fast.

enumerate() gives pairs of “index, element”. {:>3} is right-alignment in three characters; the formatting syntax here is nearly Python’s.

And numbering for a human starts at one, while indexes in a vector start at zero. Remember this spot; we trip over it four sections from now.

Commands: enum and match

The application is interactive: you start it, it waits for a command, you type add buy milk and it adds one. Which means the line has to be parsed.

And here is where the thing that makes people stay with Rust begins.

enum Command {
  Add(String),
  Done(usize),
  Remove(usize),
  List,
  Quit,
  Unknown(String),
}

This isn’t an enumeration of numbers the way it is in C. It’s an algebraic type: “the value is exactly one out of this list, and each variant can drag its own data along with it”. Add carries a string, Done a number, List carries nothing. One Command value takes up as much as the fattest variant plus a mark saying which variant it is: the String inside Add weighs 24 bytes, and size_of::<Command>() gives 32.

The parsing:

fn parse(line: &str) -> Command {
  let line = line.trim();

  let (word, rest) = match line.split_once(' ') {
    Some((word, rest)) => (word, rest.trim()),
    None => (line, ""),
  };

  match word {
    "" | "list" | "ls" => Command::List,
    "add" | "a" => Command::Add(rest.to_string()),
    "done" | "d" => number(rest, Command::Done),
    "rm" => number(rest, Command::Remove),
    "quit" | "q" | "exit" => Command::Quit,
    other => Command::Unknown(other.to_string()),
  }
}

fn number(rest: &str, make: fn(usize) -> Command) -> Command {
  match rest.parse::<usize>() {
    Ok(n) => make(n),
    Err(_) => Command::Unknown(format!("that is not a number: {rest:?}")),
  }
}

A lot going on here, in order.

let line = line.trim(); — yes, the variable redefines itself. It’s called shadowing and it’s completely normal in Rust: the new line has type &str and simply covers the old one. Especially handy when you’re parsing something and the type changes along the way.

split_once(' ') returns Option<(&str, &str)> — either Some(pair), or None when there’s no space. Option is what Rust has instead of null, and it’s one of the two or three things that make the language worth trying: a value that might not be there cannot exist unnoticed. The compiler will make you write what happens in the None case. Which is also why Rust has no NullPointerException: there’s no null.

match is a switch that can do everything. It takes enum variants apart, pulls the data out of them (Some((word, rest)) — and word and rest are already in your hands), groups variants with |, and above all it is exhaustive. Forget a branch and it won’t build. Add a new variant to Command tomorrow and the compiler shows you every place that doesn’t handle it. On a refactor that’s a rescue.

rest.parse::<usize>() parses a string into a number. It returns Result<usize, ParseIntError>: either Ok(number) or Err(error). Same story as with Option: the error can’t go unnoticed, because the value sits inside it and the only way to get it out is to take it apart.

fn(usize) -> Command in the arguments of number is a function pointer. And Command::Done is a function in its own right: an enum variant with data is constructed by calling it, which means you can pass it around as a value. Which is why number(rest, Command::Done) works. Small thing, but a pleasant one.

{rest:?} inside format! is an inline argument: the variable’s name straight in the braces, no second argument. It landed in Rust 1.58, in January 2022, and has nothing to do with editions — it works the same on all four. Worth keeping the difference in mind: an edition changes syntax, a compiler version adds capability, and those are two different axes.

One more thing you might not have thought about: an empty line parses into Command::List here. Press Enter, see the list. That’s not out of a textbook, it’s just convenient, and I decided it while testing.

The loop

use std::io::{self, Write};

fn main() {
  let mut tasks: Vec<Task> = Vec::new();

  println!("todo. commands: add <text>, done <N>, rm <N>, list, quit");

  loop {
    print!("> ");
    io::stdout().flush().unwrap();

    let mut line = String::new();
    let read = io::stdin().read_line(&mut line).unwrap();
    if read == 0 {
      println!();
      break;
    }

    // ...command parsing goes here
  }
}

loop is an infinite loop, and you leave it with break. There’s also while and for, but when a loop really is infinite people write loop: the compiler knows about it and infers types better.

print! without the ln doesn’t break the line — and terminal output is buffered, so the > prompt simply won’t appear without flush() until the buffer fills. It looks like “the program hung”. A trap out of nowhere, and I walked into it personally.

read_line(&mut line) — note that the string is passed as a mutable reference, and the function appends into it rather than returning a new one. That style shows up often in the standard library: the caller allocates the buffer, the callee fills it. Fewer allocations.

What it returns is the number of bytes read. Zero means end of stream — read: the user pressed Ctrl-D.

That has to be handled. Otherwise, on Ctrl-D, the program flies off into an infinite loop over an empty line, printing the prompt a thousand times a second. I discovered this immediately, naturally.

And .unwrap() is “you promised me a Result, give me the value, and if there’s an error in there, just fall over”. Ugly. Nobody writes that in normal code. But Result and the ? operator are part two’s subject, and here I’m deliberately leaving the crutch in so as not to dump everything at once. We’ll come back to it, honestly.

Second thing you trip over: borrow it or change it, not both

I want a “clear out everything finished” command. I write it the obvious way:

for (i, task) in tasks.iter().enumerate() {
  if task.done {
    tasks.remove(i);
  }
}
error[E0502]: cannot borrow `tasks` as mutable because it is also borrowed as immutable
  --> src/main.rs:15:7
   |
13 |   for (i, task) in tasks.iter().enumerate() {
   |                    ------------------------
   |                    |
   |                    immutable borrow occurs here
   |                    immutable borrow later used here
14 |     if task.done {
15 |       tasks.remove(i);
   |       ^^^^^^^^^^^^^^^ mutable borrow occurs here

Now this is the borrow checker. The second half of the system, after ownership.

Few rules again. Two:

  • references for reading (&T) can exist any number at a time;
  • a reference for writing (&mut T) can only be one, and at that moment there must be no references for reading.

Put simply: either many readers, or one writer. That’s all.

Here tasks.iter() holds a reading reference to the whole vector while the loop runs. And remove wants a writing one. Not allowed.

And this isn’t nitpicking out of nowhere. Changing a collection while you’re walking it is a classic bug; it just looks different in each language. In C++ it’s an invalidated iterator and a segfault. Or, far worse, not a segfault. In Java it’s a ConcurrentModificationException at runtime, at the customer’s. In Python it’s a silently skipped element you find a month later.

Rust catches it at compile time. For free. Always.

Or more precisely: always, until you ask for the opposite yourself. RefCell and Mutex move the same check to runtime, and breaking the rule there ends in a panic rather than a build error. But that’s a deliberate step, not something you blunder into through inattention.

This is where it starts to sink in what exactly you’re paying for with all that uncooperativeness.

Those two rules, incidentally, are half of what conference talks call “fearless concurrency”. A data race is “two writers”, or “a writer and a reader at once” — which is exactly what the borrow checker already forbids, and it doesn’t care whether this is happening in one thread or two. The other half is the Send and Sync traits: they decide what can be handed to another thread at all and what can be shared. There’ll be a separate conversation about those on the day you first try to drag an Rc into thread::spawn.

And the right answer to the problem here is one method:

tasks.retain(|task| !task.done);

retain keeps the elements the closure returned true for. |task| ... is a lambda. Inside retain there’s a writing reference to the vector, and no other references are alive at that moment — no conflict.

The moral, which arrives around day three: when the borrow checker complains about a loop, the standard library almost always already has a method that does exactly that. retain, iter_mut, drain, split_off. Rust really dislikes you juggling indexes by hand, and it’s usually a signal that you’re doing something wrong.

Third thing you trip over: usize doesn’t do negative

Assembling the done command:

Command::Done(n) => match tasks.get_mut(n - 1) {
  Some(task) => {
    task.done = true;
    println!("  done: {}", task.title);
  }
  None => println!("  no task numbered {n}"),
}

The logic is simple: a person counts from one, a vector from zero, subtract one. get_mut returns Option<&mut Task>, meaning None when the index runs off the end. So done 99 works without a panic.

Aren’t I clever.

I start it. I poke at it. done 99 — “no task numbered 99”. Excellent.

done 0

thread 'main' (1621248) panicked at src/main.rs:89:47:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

And there we are.

n is a usize, an unsigned integer the size of a pointer. 0 - 1 for an unsigned isn’t minus one. It’s an overflow.

And here Rust does something worth remembering: in a debug build, integer overflow is a panic. Not a quiet wrap around to 18446744073709551615, off which get_mut would have returned None and the program would have carried on as though nothing happened. A crash, a file, a line number.

(In a release build the check is removed for speed, and there it will wrap. That’s a deliberate trade-off, and it’s exactly why you debug in debug.)

The fix:

Command::Done(n) => match n.checked_sub(1).and_then(|i| tasks.get_mut(i)) {

checked_sub(1) returns Option<usize>: Some(n-1), or None if it would have gone negative. and_then is “if there’s something inside, feed it onward”. The upshot is that both zero and 99 honestly give None, and both errors are handled by one branch.

For rm I wrote it differently, as a plain range check:

Command::Remove(n) => {
  if n >= 1 && n <= tasks.len() {
    let task = tasks.remove(n - 1);
    println!("  removed: {}", task.title);
  } else {
    println!("  no task numbered {n}");
  }
}

Because remove doesn’t return an Option; it panics on a bad index, and there’s no wrapping that up neatly through and_then. Yes, two different styles in one file — but each in its own place, and that seems more honest to me than stretching one technique over both cases.

While we’re here, look at what tasks.remove(n - 1) does: it returns the removed task. Ownership moved out of the vector and into the variable task, which is why it can be printed without ceremony. That same ownership, only in the other direction.

All of it together

use std::io::{self, Write};

#[derive(Debug)]
struct Task {
  title: String,
  done: bool,
}

impl Task {
  fn new(title: String) -> Task {
    Task { title, done: false }
  }

  fn mark(&self) -> char {
    if self.done { 'x' } else { ' ' }
  }
}

enum Command {
  Add(String),
  Done(usize),
  Remove(usize),
  List,
  Quit,
  Unknown(String),
}

fn parse(line: &str) -> Command {
  let line = line.trim();

  let (word, rest) = match line.split_once(' ') {
    Some((word, rest)) => (word, rest.trim()),
    None => (line, ""),
  };

  match word {
    "" | "list" | "ls" => Command::List,
    "add" | "a" => Command::Add(rest.to_string()),
    "done" | "d" => number(rest, Command::Done),
    "rm" => number(rest, Command::Remove),
    "quit" | "q" | "exit" => Command::Quit,
    other => Command::Unknown(other.to_string()),
  }
}

fn number(rest: &str, make: fn(usize) -> Command) -> Command {
  match rest.parse::<usize>() {
    Ok(n) => make(n),
    Err(_) => Command::Unknown(format!("that is not a number: {rest:?}")),
  }
}

fn print_list(tasks: &[Task]) {
  if tasks.is_empty() {
    println!("  (empty)");
    return;
  }

  for (i, task) in tasks.iter().enumerate() {
    println!("{:>3}. [{}] {}", i + 1, task.mark(), task.title);
  }
}

fn main() {
  let mut tasks: Vec<Task> = Vec::new();

  println!("todo. commands: add <text>, done <N>, rm <N>, list, quit");

  loop {
    print!("> ");
    io::stdout().flush().unwrap();

    let mut line = String::new();
    let read = io::stdin().read_line(&mut line).unwrap();
    if read == 0 {
      println!();
      break;
    }

    match parse(&line) {
      Command::Add(title) => {
        if title.is_empty() {
          println!("  add what?");
        } else {
          println!("  added: {title}");
          tasks.push(Task::new(title));
        }
      }
      Command::Done(n) => match n.checked_sub(1).and_then(|i| tasks.get_mut(i)) {
        Some(task) => {
          task.done = true;
          println!("  done: {}", task.title);
        }
        None => println!("  no task numbered {n}"),
      },
      Command::Remove(n) => {
        if n >= 1 && n <= tasks.len() {
          let task = tasks.remove(n - 1);
          println!("  removed: {}", task.title);
        } else {
          println!("  no task numbered {n}");
        }
      }
      Command::List => print_list(&tasks),
      Command::Unknown(what) => println!("  no such command: {what}"),
      Command::Quit => break,
    }
  }

  println!("tasks in total: {}", tasks.len());
}

A hundred-odd lines, zero dependencies. cargo run:

todo. commands: add <text>, done <N>, rm <N>, list, quit
>   added: buy milk
>   added: walk the cat
>   added: write the article
>   1. [ ] buy milk
  2. [ ] walk the cat
  3. [ ] write the article
>   done: walk the cat
>   1. [ ] buy milk
  2. [x] walk the cat
  3. [ ] write the article
>   removed: buy milk
>   1. [x] walk the cat
  2. [ ] write the article
>   no task numbered 0
>   no task numbered 99
>   no such command: nonsense
> 
tasks in total: 2

Works.

Two more commands worth remembering

cargo fmt

Formats the code. One style, no arguments about braces at review — which is probably the best thing Rust took from Go.

And since you’ve certainly noticed by now, I’ll say it straight: the standard in Rust is four spaces. That’s how rustfmt formats out of the box, that’s how the standard library is written, that’s what nearly everything you open on GitHub looks like. In the listings here there are two, because that reads better to me. This is my blog, and that’s the only argument I have.

It’s done with one file next to Cargo.toml:

# rustfmt.toml
tab_spaces = 2

After that cargo fmt considers two spaces the right answer, and cargo fmt --check stops complaining in CI. No magic: rustfmt.toml is just settings for the whole project, and they live in the repository, so everyone who clones it gets the same formatting. Which is, in fact, why it’s in there.

You don’t have to do this, and I’d go as far as saying don’t. Not because two is worse than four, but because four is what you have by default and four is what all the other people’s code you’ll be reading is written in. Indentation has no effect on compilation whatsoever: type the listings out with four spaces and everything builds exactly the same.

What else people put in there besides indentation:

  • max_width = 100 — where to wrap a line. A hundred by default, and it’s the setting people most often want to move.
  • hard_tabs = false — spaces or tabs. Spaces by default, and don’t touch it.
  • newline_style = "Unix" — what a line ends with; useful if there’s Windows on the team.

An annoying detail everybody runs into: the most interesting parts of rustfmt are nightly-only. Sorting imports into groups, wrapping long comments — all of it exists, but on a stable compiler what you’ll be told is exactly this:

Warning: can't set `group_imports = StdExternalCrate`, unstable features are only available in nightly channel.

Not an error, a warning: the file was read, the line was ignored, the formatting went through without it. The stable options cover ninety percent of cases, but it’s better to learn about the remaining ten now than after half an hour of searching for why a setting doesn’t work.

And if some chunk shouldn’t be formatted — a table of constants, say, where lining things up by hand is meaningful — you write #[rustfmt::skip] above it, and rustfmt leaves it alone.

cargo clippy

A linter. And not the kind you’re used to: clippy knows several hundred idioms and says not “there’s a space in the wrong place” but “this bit is usually written like this”. In your first weeks it’s more useful than any tutorial — just run it and read what it suggests, it’s a free review from somebody who has written Rust for longer than you have.

On the final code above, both are silent. Nice.

What we’ve got, and what’s next

One evening, a working CLI and zero dependencies. And along the way: ownership, borrowing, String versus &str, Option, Result, enums with data, match, impl, closures, slices. Honestly, that’s something like seventy percent of what day-to-day Rust needs.

And one problem.

Close the terminal and it’s all gone. Our todo list lives exactly as long as the process does, and as a place to keep tasks it’s completely useless.

Part two is where we fix that:

  • save to a file and read it back;
  • throw out every .unwrap() and finally deal with Result and the ? operator;
  • cut the one main.rs into modules, because a hundred lines in one file is fine and two hundred is already arguable;
  • write tests, which in Rust sit right next to the code;
  • build a release and put the binary on our own PATH.

And the main thing I’d want to leave behind after part one is this.

When the Rust compiler complains at you, it’s almost always right. And it explains why, honestly — not with a three-word brush-off, but with underlining, with a help, and with the edit it suggests.

This isn’t a language being difficult. It’s a language asking questions. It’s just that in other languages there was nobody to ask them, and you found out the answer in production.

Comments 0

No comments yet.