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
+1
View File
@@ -0,0 +1 @@
TASK_FILE=t.txt
+1
View File
@@ -0,0 +1 @@
/target
+16
View File
@@ -0,0 +1,16 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "dotenv"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "tasks"
version = "0.1.0"
dependencies = [
"dotenv",
]
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "tasks"
version = "0.1.0"
edition = "2024"
[dependencies]
dotenv = "0.15.0"
+43
View File
@@ -0,0 +1,43 @@
use std::io::Write;
use crate::{command::Command, error::TaskError, store::Store, task::Priority};
pub fn run(args: &[String], memory: &mut Store, out: &mut impl Write) -> Result<(), TaskError> {
match Command::parse(args)? {
Command::Add { title, priority } => {
let n = memory.add(&title, priority);
writeln!(out, "added task {}", n)?;
Ok(())
}
Command::List => {
memory
.tasks()
.iter()
.try_for_each(|x| writeln!(out, "{}", x))?;
Ok(())
}
Command::Done { id } => {
memory.complete(id)?;
writeln!(out, "completed {}", id)?;
Ok(())
}
Command::Remove { id } => {
memory.remove(id)?;
writeln!(out, "removed {}", id)?;
Ok(())
}
Command::Stats => {
let counts = memory.count_by_priority();
for p in [Priority::High, Priority::Medium, Priority::Low] {
let count = counts.get(&p).copied().unwrap_or(0);
writeln!(out, "{:<6} {}", p.label(), count)?;
}
Ok(())
}
Command::Clear => {
let n = memory.remove_completed();
writeln!(out, "cleared {} completed", n)?;
Ok(())
}
}
}
+46
View File
@@ -0,0 +1,46 @@
use crate::{
error::TaskError::{self, UnknownCommand},
task::Priority,
};
#[derive(Debug, PartialEq)]
pub enum Command {
Add { title: String, priority: Priority },
List,
Done { id: u32 },
Remove { id: u32 },
Stats,
Clear,
}
impl Command {
pub fn parse(args: &[String]) -> Result<Command, TaskError> {
let first_arg = args.first().ok_or(TaskError::NoCommand)?;
match first_arg.to_lowercase().as_str() {
"add" => {
let title = args.get(1).ok_or(TaskError::MissingTitle)?;
let priority = match args.get(2) {
Some(text) => text,
None => "medium",
};
Ok(Command::Add {
title: title.clone(),
priority: Priority::parse(priority)
.ok_or(TaskError::BadPriority(priority.to_string()))?,
})
}
"list" => Ok(Command::List),
"done" => {
let id = args.get(1).ok_or(TaskError::MissingId)?.parse()?;
Ok(Command::Done { id })
}
"remove" => {
let id = args.get(1).ok_or(TaskError::MissingId)?.parse()?;
Ok(Command::Remove { id })
}
"stats" => Ok(Command::Stats),
"clear" => Ok(Command::Clear),
e => Err(UnknownCommand(e.to_string())),
}
}
}
+73
View File
@@ -0,0 +1,73 @@
use std::{error::Error, fmt::Display, io, num::ParseIntError};
#[derive(Debug)]
pub enum TaskError {
NoCommand, // no arguments at all
UnknownCommand(String), // the word the user actually typed
MissingTitle, // `add` with nothing after it
MissingId, // `done` / `remove` with nothing after it
BadPriority(String), // the priority word that was not low/medium/high
BadId(ParseIntError), // `done abc` — wraps std's parse failure
NotFound(u32), // the id that was not in the store
BadLine(String), // a saved line that cannot be read back — carries the line
Io(io::Error), // the file could not be read or written — wraps std's error
}
impl Display for TaskError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskError::NoCommand => write!(f, "no command given"),
TaskError::UnknownCommand(e) => write!(f, "unknown command: {}", e),
TaskError::MissingTitle => write!(f, "add needs a title"),
TaskError::MissingId => write!(f, "this command needs a task id"),
TaskError::BadPriority(e) => write!(f, "unknown priority: {}", e),
TaskError::BadId(_) => write!(f, "task id must be a number"),
TaskError::NotFound(e) => write!(f, "no task with id {}", e),
TaskError::BadLine(e) => write!(f, "cannot read saved line: {}", e),
TaskError::Io(_) => write!(f, "cannot read or write the task file"),
}
}
}
impl PartialEq for TaskError {
fn eq(&self, other: &Self) -> bool {
use TaskError::*;
match (self, other) {
(UnknownCommand(a), UnknownCommand(b))
| (BadPriority(a), BadPriority(b))
| (BadLine(a), BadLine(b)) => a == b,
(BadId(a), BadId(b)) => a == b,
(NotFound(a), NotFound(b)) => a == b,
(Io(a), Io(b)) => a.kind() == b.kind(), // compare the kind, not the error
_ => std::mem::discriminant(self) == std::mem::discriminant(other),
}
}
}
impl Error for TaskError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
TaskError::Io(e) => Some(e),
TaskError::BadId(e) => Some(e),
TaskError::BadPriority(_)
| TaskError::UnknownCommand(_)
| TaskError::MissingTitle
| TaskError::NoCommand
| TaskError::MissingId
| TaskError::NotFound(_)
| TaskError::BadLine(_) => None,
}
}
}
impl From<ParseIntError> for TaskError {
fn from(value: ParseIntError) -> Self {
TaskError::BadId(value)
}
}
impl From<io::Error> for TaskError {
fn from(value: io::Error) -> Self {
TaskError::Io(value)
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod cli;
pub mod command;
pub mod error;
pub mod stats;
pub mod store;
pub mod task;
+26
View File
@@ -0,0 +1,26 @@
use dotenv::dotenv;
use std::env::{self, args};
use std::io::{self};
use std::{path::PathBuf, process};
use tasks::cli;
use tasks::error::TaskError;
use tasks::store::Store;
fn main() {
dotenv().ok();
let path = PathBuf::from(env::var("TASKS_FILE").unwrap_or_else(|_| "tasks.txt".to_string()));
let mut memory = Store::load(&path).unwrap_or_else(|e| fail(e));
let args: Vec<String> = args().skip(1).collect();
if let Err(e) = cli::run(&args, &mut memory, &mut io::stdout().lock()) {
fail(e)
}
if let Err(e) = memory.save(&path) {
fail(e)
}
}
fn fail(e: TaskError) -> ! {
eprintln!("error: {}", e);
process::exit(1);
}
+26
View File
@@ -0,0 +1,26 @@
use std::{collections::HashMap, hash::Hash};
pub fn tally<T, K, F>(items: &[T], key: F) -> HashMap<K, usize>
where
K: Eq + Hash,
F: Fn(&T) -> K,
{
let mut counts = HashMap::new();
for item in items {
*counts.entry(key(item)).or_insert(0) += 1;
}
counts
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn owned_key() {
let words = ["apple", "avocado", "banana"];
let counts = tally(&words, |w| w.chars().next().unwrap().to_string());
assert_eq!(counts.get("a"), Some(&2), "keys: {:?}", counts);
assert_eq!(counts.len(), 2, "keys: {:?}", counts);
}
}
+115
View File
@@ -0,0 +1,115 @@
use std::collections::HashMap;
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use crate::error::TaskError;
use crate::stats;
use crate::task::Priority;
use crate::task::Status;
use crate::task::Task;
#[derive(Debug, PartialEq)]
pub struct Store {
tasks: Vec<Task>,
id: u32,
}
impl Store {
pub fn new() -> Store {
Store {
tasks: Vec::new(),
id: 1,
}
}
pub fn add(&mut self, title: &str, priority: Priority) -> u32 {
self.tasks.push(Task::new(self.id, title, priority));
let id = self.id;
self.id += 1;
id
}
pub fn complete(&mut self, id: u32) -> Result<(), TaskError> {
let task = self
.tasks
.iter_mut()
.find(|task| task.id == id)
.ok_or(TaskError::NotFound(id))?;
task.status = Status::Done;
Ok(())
}
pub fn remove(&mut self, id: u32) -> Result<(), TaskError> {
let idx = self.tasks.iter().position(|x| x.id == id);
match idx {
Some(i) => {
self.tasks.remove(i);
Ok(())
}
None => Err(TaskError::NotFound(id)),
}
}
pub fn tasks(&self) -> &[Task] {
&self.tasks[..]
}
pub fn find(&self, id: u32) -> Option<&Task> {
self.tasks.iter().find(|task| task.id == id)
// for task in self.tasks.iter() {
// if task.id == id {
// return Some(task);
// }
// }
// None
}
pub fn save(&self, path: &Path) -> Result<(), TaskError> {
let contents = self
.tasks
.iter()
.map(|x| format!("{}\n", x.to_line()))
.collect::<String>();
Ok(fs::write(path, contents)?)
}
pub fn load(path: &Path) -> Result<Store, TaskError> {
let contents = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == ErrorKind::NotFound => String::new(),
Err(e) => return Err(TaskError::Io(e)),
};
let mut memory = Store::new();
memory.tasks = contents
.lines()
.map(|s| s.parse())
.collect::<Result<Vec<Task>, TaskError>>()?;
let max_id: u32 = memory.tasks.iter().map(|t| t.id).max().unwrap_or(0);
memory.id = max_id + 1;
Ok(memory)
}
pub fn count_by_priority(&self) -> HashMap<Priority, usize> {
stats::tally(&self.tasks, |t| t.priority)
}
pub fn titles_with(&self, priority: Priority) -> Vec<&str> {
self.tasks
.iter()
.filter(|x| x.priority == priority)
.map(|x| x.title.as_str())
.collect()
}
pub fn remove_completed(&mut self) -> usize {
let n = self.tasks.len();
self.tasks.retain(|x| x.status != Status::Done);
n - self.tasks.len()
}
}
+147
View File
@@ -0,0 +1,147 @@
use std::{fmt, str::FromStr};
use crate::error::TaskError;
#[derive(Debug, PartialEq)]
pub enum Status {
Todo,
InProgress,
Done,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum Priority {
Low,
Medium,
High,
}
#[derive(Debug, PartialEq)]
pub struct Task {
pub id: u32,
pub title: String,
pub priority: Priority,
pub status: Status,
}
impl fmt::Display for Task {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} [{}] {} ({})",
self.id,
self.status.label(),
self.title,
self.priority.label()
)
}
}
impl Status {
/// todo, in-progress, done
pub fn label(&self) -> &str {
match self {
Status::Todo => "todo",
Status::InProgress => "in-progress",
Status::Done => "done",
}
}
pub fn parse(status: &str) -> Option<Status> {
match status.to_lowercase().as_str() {
"in-progress" => Some(Status::InProgress),
"done" => Some(Status::Done),
"todo" => Some(Status::Todo),
_ => None,
}
}
}
impl Priority {
pub fn label(&self) -> &str {
match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
}
}
pub fn parse(text: &str) -> Option<Priority> {
match text.to_lowercase().as_str() {
"low" => Some(Priority::Low),
"medium" => Some(Priority::Medium),
"high" => Some(Priority::High),
_ => None,
}
}
}
impl Task {
/// default status is todo
// yo what is happening right here
pub fn new(id: u32, title: &str, priority: Priority) -> Task {
Task {
id,
title: title.to_string(),
priority,
status: Status::Todo,
}
}
/// id|status|priority|title
pub fn to_line(&self) -> String {
let line: String = format!(
"{}|{}|{}|{}",
self.id,
self.status.label(),
self.priority.label(),
self.title
);
line
}
}
impl FromStr for Task {
type Err = TaskError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let items: Vec<&str> = s.splitn(4, "|").collect();
let bad_line = || TaskError::BadLine(s.to_string());
if items.len() != 4 {
return Err(bad_line());
}
Ok(Task {
id: items[0].parse().map_err(|_| bad_line())?,
status: Status::parse(items[1]).ok_or_else(bad_line)?,
priority: Priority::parse(items[2]).ok_or_else(bad_line)?,
title: items[3].to_string(),
})
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn line_format() {
let t = Task::new(1, "eat something", Priority::Low);
assert_eq!(t.to_line(), "1|todo|low|eat something");
let line = t.to_line();
assert_eq!(line.parse(), Ok(t));
}
#[test]
fn status_label() {
for t in [Status::Todo, Status::InProgress, Status::Done] {
assert_eq!(Status::parse(t.label()), Some(t));
}
}
#[test]
fn user_display() {
let mut t = Task::new(1, "eat something", Priority::Low);
assert_eq!(t.to_string(), "1 [low] eat something (todo)");
t.status = Status::Done;
assert_eq!(t.to_string(), "1 [low] eat something (done)");
}
}
View File
+2
View File
@@ -0,0 +1,2 @@
1|done|high|mandi
2|todo|low|makan
+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");
}