Rust learning: lessons, notes, and exercise crates
This commit is contained in:
@@ -0,0 +1 @@
|
||||
TASK_FILE=t.txt
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+16
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "tasks"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
dotenv = "0.15.0"
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod cli;
|
||||
pub mod command;
|
||||
pub mod error;
|
||||
pub mod stats;
|
||||
pub mod store;
|
||||
pub mod task;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
1|done|high|mandi
|
||||
2|todo|low|makan
|
||||
@@ -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(¬hing, |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"
|
||||
);
|
||||
}
|
||||
Executable
+29
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user