1 The Problem
We want a to-do list you can actually use: add tasks, see them numbered, remove the ones you finish, and — crucially — have them saved to a file so they survive after you close the program. It teaches lists, a menu loop, and saving data to disk.
2 How to Think About It
The hard part of any to-do list is not the list — it is remembering it after the program exits. Think in two halves: the in-memory list, and the file that survives between runs.
3 The Build — explained part by part
Here is the complete to-do list. Notice that load_tasks/save_tasks are the only two functions that touch the filesystem — everything else works on a plain Vec<Task> and is trivial to test.
use std::env;
use std::fs;
#[derive(Debug, Clone, PartialEq)]
struct Task {
done: bool,
text: String,
}
const FILE_PATH: &str = "tasks.txt";
/// Serializes tasks to a tiny hand-rolled line format: `1|text` for a done
/// task, `0|text` for a pending one. A real project would reach for the
/// `serde` and `serde_json` crates here, the way Go's version of this
/// project used the standard library's own `encoding/json`, but those are
/// external crates and this build environment cannot fetch crates.io, so
/// this is a plain, dependency-free stand-in that is still a real, working
/// file format.
fn serialize(tasks: &[Task]) -> String {
tasks
.iter()
.map(|t| format!("{}|{}", if t.done { 1 } else { 0 }, t.text))
.collect::<Vec<_>>()
.join("\n")
}
fn deserialize(data: &str) -> Vec<Task> {
data.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let (flag, text) = line.split_once('|')?;
Some(Task {
done: flag == "1",
text: text.to_string(),
})
})
.collect()
}
fn load_tasks(path: &str) -> Vec<Task> {
fs::read_to_string(path)
.map(|data| deserialize(&data))
.unwrap_or_default()
}
fn save_tasks(path: &str, tasks: &[Task]) -> std::io::Result<()> {
fs::write(path, serialize(tasks))
}
fn add_task(tasks: &mut Vec<Task>, text: &str) {
tasks.push(Task {
done: false,
text: text.to_string(),
});
}
/// Marks the task at `index` (1-based, as shown to the user) done. Returns
/// `false` if the index is out of range, instead of panicking.
fn mark_done(tasks: &mut [Task], index: usize) -> bool {
match index.checked_sub(1).and_then(|i| tasks.get_mut(i)) {
Some(task) => {
task.done = true;
true
}
None => false,
}
}
/// Removes the task at `index` (1-based). Returns `false` if out of range.
fn remove_task(tasks: &mut Vec<Task>, index: usize) -> bool {
match index.checked_sub(1) {
Some(i) if i < tasks.len() => {
tasks.remove(i);
true
}
_ => false,
}
}
fn format_list(tasks: &[Task]) -> String {
tasks
.iter()
.enumerate()
.map(|(i, t)| {
format!(
"{}. [{}] {}",
i + 1,
if t.done { "x" } else { " " },
t.text
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn main() {
let mut tasks = load_tasks(FILE_PATH);
let args: Vec<String> = env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("add") => {
let text = args[1..].join(" ");
add_task(&mut tasks, &text);
println!("Added: {text}");
}
Some("done") => {
let index: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
if mark_done(&mut tasks, index) {
println!("Marked task {index} done.");
} else {
println!("No task #{index}.");
}
}
Some("remove") => {
let index: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
if remove_task(&mut tasks, index) {
println!("Removed task {index}.");
} else {
println!("No task #{index}.");
}
}
Some("list") | None => {
println!("{}", format_list(&tasks));
}
Some(other) => {
println!("Unknown command '{other}'. Use add, done, remove, or list.");
}
}
if let Err(e) = save_tasks(FILE_PATH, &tasks) {
eprintln!("Could not save tasks: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_serialize_and_deserialize() {
let tasks = vec![
Task { done: false, text: "Buy milk".into() },
Task { done: true, text: "Call mom".into() },
];
let data = serialize(&tasks);
assert_eq!(deserialize(&data), tasks);
}
#[test]
fn mark_done_updates_the_right_task_and_rejects_bad_index() {
let mut tasks = vec![
Task { done: false, text: "A".into() },
Task { done: false, text: "B".into() },
];
assert!(mark_done(&mut tasks, 2));
assert!(tasks[1].done);
assert!(!tasks[0].done);
assert!(!mark_done(&mut tasks, 99));
}
#[test]
fn remove_task_shrinks_the_list() {
let mut tasks = vec![
Task { done: false, text: "A".into() },
Task { done: false, text: "B".into() },
Task { done: false, text: "C".into() },
];
assert!(remove_task(&mut tasks, 2));
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[1].text, "C");
assert!(!remove_task(&mut tasks, 0));
}
}
rustup) is installed.derive generates boilerplate implementations automatically: Debug for printing, Clone for copying, and PartialEq for the == comparisons the tests below use — all without writing a line of that code by hand.serialize / deserialize — a deliberately simple, hand-rolled file format (
1|Buy milk) rather than real JSON. A production project would reach for the serde and serde_json crates here, exactly the way Go’s version of this project used its standard library’s own encoding/json. Those are external crates, unavailable in this build environment, which is a genuine and useful difference to notice: Go ships JSON support in its standard library, Rust deliberately keeps it out and expects you to add a crate.index.checked_sub(1).and_then(|i| tasks.get_mut(i)) — the task numbers shown to the user start at 1, but Rust
Vec indices start at 0. checked_sub(1) converts safely — on task “0” it would underflow a usize, so checked_sub returns None instead of panicking, and get_mut then returns None again if the index is simply too large.tasks.remove(i) — removes the element at index
i and shifts every later element down by one, an O(n) operation worth knowing about if the list ever gets large.
tasks[index - 1] using the user’s 1-based number — if index is 0, index - 1 underflows a usize and panics instead of failing gracefully.checked_sub(1) and .get_mut()/.get(), which return Option instead of panicking on a bad index.save_tasks once, unconditionally, right before main returns, as the code above does.4 Test & Prove Each Part
We test the persistence format and the mutation logic entirely in memory, with no real file on disk.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_serialize_and_deserialize() {
let tasks = vec![
Task { done: false, text: "Buy milk".into() },
Task { done: true, text: "Call mom".into() },
];
let data = serialize(&tasks);
assert_eq!(deserialize(&data), tasks);
}
#[test]
fn mark_done_updates_the_right_task_and_rejects_bad_index() {
let mut tasks = vec![
Task { done: false, text: "A".into() },
Task { done: false, text: "B".into() },
];
assert!(mark_done(&mut tasks, 2));
assert!(tasks[1].done);
assert!(!tasks[0].done);
assert!(!mark_done(&mut tasks, 99));
}
#[test]
fn remove_task_shrinks_the_list() {
let mut tasks = vec![
Task { done: false, text: "A".into() },
Task { done: false, text: "B".into() },
Task { done: false, text: "C".into() },
];
assert!(remove_task(&mut tasks, 2));
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[1].text, "C");
assert!(!remove_task(&mut tasks, 0));
}
}
Run with cargo test. The round-trip test is the most important one: it builds a Vec<Task>, serializes it, deserializes the result, and asserts the two lists are == — which only compiles because Task derives PartialEq.
5 The Interface
What it expects
add Buy milk
done 1
remove 2
listWhat it returns
1. [x] Buy milk
2. [ ] Call mom6 Run It & Automate It
Save the code as src/main.rs inside a Cargo project's src/ folder and run it with cargo run — Cargo compiles and executes in one step while you are experimenting, then cargo build --release gives you an optimized binary once you are done.
cargo run -- add Buy milkTasks are saved to
tasks.txt in the current directory and reloaded automatically next run.A CI tool like Jenkins runs cargo test automatically whenever the code changes — every line below has a plain explanation.
$ cargo run -- add Buy milk
Added: Buy milk
$ cargo run -- add Call mom
Added: Call mom
$ cargo run -- done 1
Marked task 1 done.
$ cargo run -- list
1. [x] Buy milk
2. [ ] Call momcargo run is being run from the same directory each time — tasks.txt is created relative to the current working directory.// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Rust') {
steps {
sh 'rustc --version' // confirm Rust is installed
sh 'cargo build' // compile, downloading any crates
}
}
stage('Run the tests') {
steps {
sh 'cargo clippy -- -D warnings' // catch obvious mistakes before running
sh 'cargo test' // run every test, show each result
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Add due dates. Extend
Taskwith anOption<String>field. (Teaches: optional struct fields.) - Switch to real JSON. If you have network access,
cargo add serde serde_jsonand deriveSerialize/DeserializeonTask. (Teaches: derive macros from an external crate.) - Sort by status. Show incomplete tasks before done ones. (Teaches:
Vec::sort_by_key.)
#[derive]d struct, separate pure logic from file I/O so it stays trivially testable, and handle a user-facing 1-based index safely with checked_sub instead of risking a panic. You also saw a real, honest example of Rust’s thin-standard-library philosophy compared to Go’s batteries-included one. Related: Structs, Collections.