Rust learning: lessons, notes, and exercise crates

This commit is contained in:
2026-09-06 23:59:08 +07:00
commit 6ddf41aa81
119 changed files with 10873 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
// The 0008 specification, as executable tests. Do not edit this file — make it pass.
// Copy to: tasks/tests/collections.rs
// Run with: cargo test
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use tasks::stats::tally;
use tasks::store::Store;
use tasks::task::Priority;
// a fresh path per test, inside the OS temp dir — no file is ever left in your crate
fn temp_path() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("tasks-coll-{}-{}.txt", std::process::id(), n))
}
// ---------- tally: one generic function, any element type, any key type ----------
#[test]
fn tally_counts_how_often_each_key_appears() {
let words = ["red", "blue", "red", "green", "red"];
let counts = tally(&words, |word| *word);
assert_eq!(counts[&"red"], 3);
assert_eq!(counts[&"blue"], 1);
assert_eq!(counts.len(), 3);
}
#[test]
fn tally_accepts_a_key_type_that_is_not_the_element_type() {
let words = ["a", "bb", "cc", "ddd"];
let by_length = tally(&words, |word| word.len());
assert_eq!(by_length, HashMap::from([(1, 1), (2, 2), (3, 1)]));
}
#[test]
fn tally_of_nothing_is_an_empty_map() {
let nothing: [u32; 0] = [];
assert!(tally(&nothing, |n| *n).is_empty());
}
// ---------- counting the store, built on that same function ----------
#[test]
fn count_by_priority_counts_every_priority_present() {
let mut store = Store::new();
store.add("buy milk", Priority::High);
store.add("call bank", Priority::High);
store.add("water plants", Priority::Low);
assert_eq!(
store.count_by_priority(),
HashMap::from([(Priority::High, 2), (Priority::Low, 1)])
);
}
#[test]
fn count_by_priority_has_no_entry_for_an_absent_priority() {
let mut store = Store::new();
store.add("buy milk", Priority::High);
let counts = store.count_by_priority();
assert_eq!(counts.len(), 1, "absent priorities must not appear as zero");
assert_eq!(counts.get(&Priority::Low), None);
}
#[test]
fn count_by_priority_of_an_empty_store_is_empty() {
assert!(Store::new().count_by_priority().is_empty());
}
// ---------- selecting titles: filter, map, collect ----------
#[test]
fn titles_with_returns_matching_titles_in_order() {
let mut store = Store::new();
store.add("buy milk", Priority::High);
store.add("water plants", Priority::Low);
store.add("call bank", Priority::High);
assert_eq!(store.titles_with(Priority::High), ["buy milk", "call bank"]);
}
#[test]
fn titles_with_returns_nothing_when_no_task_matches() {
let mut store = Store::new();
store.add("buy milk", Priority::High);
assert!(store.titles_with(Priority::Medium).is_empty());
}
// ---------- clearing out finished work: retain ----------
#[test]
fn remove_completed_drops_done_tasks_and_reports_how_many() {
let mut store = Store::new();
let first = store.add("buy milk", Priority::High);
store.add("water plants", Priority::Low);
let third = store.add("call bank", Priority::Medium);
store.complete(first).unwrap();
store.complete(third).unwrap();
assert_eq!(store.remove_completed(), 2);
assert_eq!(store.titles_with(Priority::Low), ["water plants"]);
assert_eq!(store.tasks().len(), 1);
}
#[test]
fn remove_completed_removes_nothing_when_nothing_is_done() {
let mut store = Store::new();
store.add("buy milk", Priority::High);
store.add("call bank", Priority::Low);
assert_eq!(store.remove_completed(), 0);
assert_eq!(store.tasks().len(), 2);
}
#[test]
fn remove_completed_keeps_the_remaining_ids_unchanged() {
let mut store = Store::new();
let first = store.add("buy milk", Priority::High);
store.add("call bank", Priority::Low);
store.complete(first).unwrap();
store.remove_completed();
assert_eq!(store.tasks()[0].id, 2, "surviving tasks keep their own id");
assert_eq!(
store.add("new one", Priority::Low),
3,
"and the counter is untouched"
);
}
// ---------- load: one collect, and the line rules that come with it ----------
#[test]
fn a_file_without_a_trailing_newline_still_loads() {
let path = temp_path();
std::fs::write(&path, "1|todo|low|fine").unwrap();
let store = Store::load(&path).unwrap();
assert_eq!(store.tasks().len(), 1);
}
#[test]
fn a_blank_line_inside_the_file_is_a_bad_line() {
let path = temp_path();
std::fs::write(&path, "1|todo|low|fine\n\n2|todo|low|also fine\n").unwrap();
let err = Store::load(&path).unwrap_err();
assert_eq!(err.to_string(), "cannot read saved line: ");
}
#[test]
fn one_bad_line_loses_the_whole_load_not_part_of_it() {
let path = temp_path();
std::fs::write(&path, "1|todo|low|fine\nrubbish\n3|todo|low|also fine\n").unwrap();
assert!(Store::load(&path).is_err());
// the file itself is untouched by a failed load — nothing was half-written
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"1|todo|low|fine\nrubbish\n3|todo|low|also fine\n"
);
}
+29
View File
@@ -0,0 +1,29 @@
#![allow(dead_code)]
use std::{
path::PathBuf,
sync::atomic::{AtomicU32, Ordering},
};
use tasks::store::Store;
use tasks::task::Priority;
pub fn temp_path() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("tasks-{}-{}.txt", std::process::id(), n))
}
pub fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
/// One task per priority, ids 1..=3, task 1 already completed.
pub fn three_tasks() -> Store {
let mut store = Store::new();
store.add("low task", Priority::Low);
store.add("medium task", Priority::Medium);
store.add("high task", Priority::High);
store.complete(1).expect("task 1 was just added");
store
}
+104
View File
@@ -0,0 +1,104 @@
// The 0006 specification, as executable tests. Do not edit this file — make it pass.
// Copy to: tasks/tests/errors.rs
// Run with: cargo test
use std::error::Error;
use tasks::command::Command;
use tasks::error::TaskError;
use tasks::store::Store;
use tasks::task::Priority;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
// ---------- one variant per way of failing ----------
#[test]
fn parse_errors_name_the_exact_failure() {
assert_eq!(Command::parse(&args(&[])).unwrap_err(), TaskError::NoCommand);
assert_eq!(
Command::parse(&args(&["fly"])).unwrap_err(),
TaskError::UnknownCommand("fly".to_string())
);
assert_eq!(
Command::parse(&args(&["add"])).unwrap_err(),
TaskError::MissingTitle
);
assert_eq!(
Command::parse(&args(&["add", "x", "urgent"])).unwrap_err(),
TaskError::BadPriority("urgent".to_string())
);
assert_eq!(
Command::parse(&args(&["done"])).unwrap_err(),
TaskError::MissingId
);
}
#[test]
fn store_reports_which_id_was_missing() {
let mut store = Store::new();
store.add("only task", Priority::Low);
assert_eq!(store.complete(99).unwrap_err(), TaskError::NotFound(99));
assert_eq!(store.remove(7).unwrap_err(), TaskError::NotFound(7));
}
// ---------- Display: the sentence the user reads ----------
#[test]
fn messages_are_lowercase_and_unpunctuated() {
assert_eq!(TaskError::NoCommand.to_string(), "no command given");
assert_eq!(
TaskError::UnknownCommand("fly".to_string()).to_string(),
"unknown command: fly"
);
assert_eq!(TaskError::MissingTitle.to_string(), "add needs a title");
assert_eq!(
TaskError::MissingId.to_string(),
"this command needs a task id"
);
assert_eq!(
TaskError::BadPriority("urgent".to_string()).to_string(),
"unknown priority: urgent"
);
assert_eq!(TaskError::NotFound(9).to_string(), "no task with id 9");
}
// ---------- From + ? : the parse error is wrapped, not thrown away ----------
#[test]
fn a_bad_id_wraps_the_parse_error() {
let err = Command::parse(&args(&["done", "abc"])).unwrap_err();
assert!(matches!(err, TaskError::BadId(_)), "expected BadId");
assert!(
Command::parse(&args(&["remove", "-1"])).is_err(),
"a negative id is not a u32"
);
}
#[test]
fn a_wrapped_error_is_reported_once_and_reachable() {
let err = Command::parse(&args(&["done", "abc"])).unwrap_err();
// Display says your sentence and does not repeat std's.
assert_eq!(err.to_string(), "task id must be a number");
// std's sentence is still reachable, through Error::source().
let inner = err.source().expect("BadId must expose its source");
assert_eq!(inner.to_string(), "invalid digit found in string");
}
#[test]
fn errors_that_carry_nothing_have_no_source() {
assert!(TaskError::NoCommand.source().is_none());
assert!(TaskError::NotFound(1).source().is_none());
}
// ---------- the Error trait, and the bounds the ecosystem expects ----------
#[test]
fn task_error_is_a_real_error() {
fn assert_usable_as_error<E: Error + Send + Sync + 'static>() {}
assert_usable_as_error::<TaskError>();
let boxed: Box<dyn Error> = Box::new(TaskError::NotFound(1));
assert_eq!(boxed.to_string(), "no task with id 1");
}
+47
View File
@@ -0,0 +1,47 @@
use std::fs;
use tasks::error::TaskError;
use tasks::store::Store;
use tasks::task::Status;
use crate::common::{temp_path, three_tasks};
mod common;
#[test]
fn completing_task_that_are_done_is_not_an_error() -> Result<(), TaskError> {
let mut m = three_tasks();
m.complete(1)?;
let t = m.find(1).unwrap();
assert_eq!(t.status, Status::Done);
Ok(())
}
#[test]
fn reload_saved_working() -> Result<(), TaskError> {
let mut m = three_tasks();
let p = temp_path();
m.remove_completed();
m.save(&p)?;
let m2 = Store::load(&p)?;
assert_eq!(m2, m);
Ok(())
}
#[test]
fn smaller_store_smaller_saved_files() -> Result<(), TaskError> {
let mut m = three_tasks();
let p = temp_path();
m.save(&p)?;
m.remove_completed();
m.save(&p)?;
let s = fs::read_to_string(p)?;
assert_eq!(s.lines().count(), 2);
Ok(())
}
+106
View File
@@ -0,0 +1,106 @@
// The 0007 specification, as executable tests. Do not edit this file — make it pass.
// Copy to: tasks/tests/persist.rs
// Run with: cargo test
use std::error::Error;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use tasks::error::TaskError;
use tasks::store::Store;
use tasks::task::{Priority, Status, Task};
// a fresh path per test, inside the OS temp dir — no file is ever left in your crate
fn temp_path() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("tasks-test-{}-{}.txt", std::process::id(), n))
}
// ---------- a Task survives the trip to text and back ----------
#[test]
fn a_task_becomes_a_line_and_a_line_becomes_a_task() {
let task = Task {
id: 3,
title: String::from("buy milk"),
priority: Priority::High,
status: Status::Done,
};
assert_eq!(task.to_line(), "3|done|high|buy milk");
assert_eq!("3|done|high|buy milk".parse::<Task>().unwrap(), task);
}
#[test]
fn a_title_may_contain_the_separator() {
let line = "1|todo|low|read a|b testing";
let task: Task = line.parse().unwrap();
assert_eq!(task.title, "read a|b testing");
assert_eq!(task.to_line(), line);
}
#[test]
fn a_corrupt_line_names_itself() {
for bad in ["", "nonsense", "1|todo|low", "x|todo|low|t", "1|sleeping|low|t", "1|todo|urgent|t"] {
assert_eq!(
bad.parse::<Task>().unwrap_err(),
TaskError::BadLine(bad.to_string()),
"line {:?} should be reported as a bad line",
bad
);
}
}
// ---------- the store round trip ----------
#[test]
fn every_task_survives_save_then_load() {
let path = temp_path();
let mut store = Store::new();
store.add("buy milk", Priority::High);
let second = store.add("call bank", Priority::Medium);
store.complete(second).unwrap();
store.save(&path).unwrap();
let loaded = Store::load(&path).unwrap();
assert_eq!(loaded.tasks(), store.tasks());
}
#[test]
fn ids_do_not_restart_after_a_reload() {
let path = temp_path();
let mut store = Store::new();
store.add("first", Priority::Low);
store.add("second", Priority::Low);
store.save(&path).unwrap();
let mut loaded = Store::load(&path).unwrap();
assert_eq!(loaded.add("third", Priority::Low), 3);
}
#[test]
fn a_missing_file_is_an_empty_store_not_an_error() {
let path = temp_path(); // never created
let store = Store::load(&path).expect("first run must not fail");
assert!(store.tasks().is_empty());
}
#[test]
fn a_corrupt_file_fails_loudly() {
let path = temp_path();
std::fs::write(&path, "1|todo|low|fine\nrubbish\n").unwrap();
assert_eq!(
Store::load(&path).unwrap_err(),
TaskError::BadLine(String::from("rubbish"))
);
}
// ---------- io::Error becomes TaskError, and keeps its cause ----------
#[test]
fn a_write_that_cannot_happen_is_an_io_error() {
let path = temp_path().join("no-such-dir").join("t.txt");
let err = Store::new().save(&path).unwrap_err();
assert!(matches!(err, TaskError::Io(_)), "got {:?}", err);
assert_eq!(err.to_string(), "cannot read or write the task file");
assert!(err.source().is_some(), "the io::Error must stay reachable");
}
+184
View File
@@ -0,0 +1,184 @@
// The specification, as executable tests. Do not edit this file — make it pass.
// Copy to: tasks/tests/spec.rs
// Run with: cargo test
use tasks::command::Command;
use tasks::store::Store;
use tasks::task::{Priority, Status, Task};
/// Helper: build an argument list the way main() would pass it in
/// (program name already stripped).
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
// ---------- task.rs ----------
#[test]
fn new_task_starts_as_todo() {
let task = Task::new(1, "write the spec", Priority::High);
assert_eq!(task.id, 1);
assert_eq!(task.title, "write the spec");
assert_eq!(task.status, Status::Todo);
assert_eq!(task.priority, Priority::High);
}
#[test]
fn status_labels() {
assert_eq!(Status::Todo.label(), "todo");
assert_eq!(Status::InProgress.label(), "in-progress");
assert_eq!(Status::Done.label(), "done");
}
#[test]
fn priority_labels() {
assert_eq!(Priority::Low.label(), "low");
assert_eq!(Priority::Medium.label(), "medium");
assert_eq!(Priority::High.label(), "high");
}
#[test]
fn priority_parses_known_words_only() {
assert_eq!(Priority::parse("low"), Some(Priority::Low));
assert_eq!(Priority::parse("medium"), Some(Priority::Medium));
assert_eq!(Priority::parse("high"), Some(Priority::High));
assert_eq!(Priority::parse("urgent"), None);
assert_eq!(Priority::parse(""), None);
}
// ---------- command.rs ----------
#[test]
fn parses_add_with_default_priority() {
let command = Command::parse(&args(&["add", "buy milk"])).unwrap();
assert_eq!(
command,
Command::Add {
title: "buy milk".to_string(),
priority: Priority::Medium,
}
);
}
#[test]
fn parses_add_with_explicit_priority() {
let command = Command::parse(&args(&["add", "ship it", "high"])).unwrap();
assert_eq!(
command,
Command::Add {
title: "ship it".to_string(),
priority: Priority::High,
}
);
}
#[test]
fn parses_list_done_and_remove() {
assert_eq!(Command::parse(&args(&["list"])).unwrap(), Command::List);
assert_eq!(
Command::parse(&args(&["done", "7"])).unwrap(),
Command::Done { id: 7 }
);
assert_eq!(
Command::parse(&args(&["remove", "12"])).unwrap(),
Command::Remove { id: 12 }
);
}
#[test]
fn rejects_bad_input() {
assert!(Command::parse(&args(&[])).is_err(), "no command at all");
assert!(Command::parse(&args(&["fly"])).is_err(), "unknown command");
assert!(Command::parse(&args(&["add"])).is_err(), "add with no title");
assert!(
Command::parse(&args(&["add", "x", "urgent"])).is_err(),
"unknown priority word"
);
assert!(Command::parse(&args(&["done"])).is_err(), "done with no id");
assert!(
Command::parse(&args(&["done", "abc"])).is_err(),
"id is not a number"
);
assert!(
Command::parse(&args(&["remove", "-1"])).is_err(),
"negative id is not a u32"
);
}
// ---------- store.rs ----------
#[test]
fn add_returns_ids_starting_at_one() {
let mut store = Store::new();
assert_eq!(store.add("first", Priority::Low), 1);
assert_eq!(store.add("second", Priority::Low), 2);
assert_eq!(store.add("third", Priority::Low), 3);
}
#[test]
fn tasks_come_back_in_insertion_order() {
let mut store = Store::new();
store.add("first", Priority::Low);
store.add("second", Priority::High);
let listed = store.tasks();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].title, "first");
assert_eq!(listed[1].title, "second");
}
#[test]
fn new_store_is_empty() {
let store = Store::new();
assert!(store.tasks().is_empty());
}
#[test]
fn complete_sets_status_to_done() {
let mut store = Store::new();
let id = store.add("do the thing", Priority::Medium);
assert_eq!(store.find(id).unwrap().status, Status::Todo);
assert!(store.complete(id).is_ok());
assert_eq!(store.find(id).unwrap().status, Status::Done);
}
#[test]
fn complete_unknown_id_is_an_error() {
let mut store = Store::new();
store.add("only task", Priority::Low);
assert!(store.complete(99).is_err());
}
#[test]
fn remove_deletes_only_that_task() {
let mut store = Store::new();
let first = store.add("first", Priority::Low);
let second = store.add("second", Priority::Low);
assert!(store.remove(first).is_ok());
assert_eq!(store.tasks().len(), 1);
assert!(store.find(first).is_none());
assert!(store.find(second).is_some());
}
#[test]
fn remove_unknown_id_is_an_error() {
let mut store = Store::new();
assert!(store.remove(1).is_err());
}
#[test]
fn find_returns_none_for_missing_id() {
let store = Store::new();
assert!(store.find(1).is_none());
}
#[test]
fn ids_are_not_reused_after_remove() {
let mut store = Store::new();
let first = store.add("first", Priority::Low);
store.remove(first).unwrap();
let second = store.add("second", Priority::Low);
assert_ne!(first, second, "a removed id must not be handed out again");
}