все посты
Серия Rust: первые шаги Часть 2 из 2

Rust: первые шаги. Todo, который помнит

В конце первой части у вас осталась работающая программа: сто строк, ноль зависимостей. Она добавляет задачи, вычёркивает их и удаляет.

И теряет всё при выходе.

Закрыли терминал — списка нет. Нажали Ctrl-C — списка нет. Вышли по-честному, через quit, — списка всё равно нет. Список дел, который ничего не помнит, бесполезен.

Научим его помнить. По шагам:

  1. разложим код по файлам — хранилищу нужен свой;
  2. придумаем, как задача выглядит в файле;
  3. научимся читать файл и писать его;
  4. подключим всё это к программе и уберём .unwrap();
  5. напишем тесты;
  6. соберём релиз и поставим программу себе.

По дороге разберём модули, Result и оператор ?. Зависимостей по-прежнему ноль.

Шаг 1. Раскладываем код по файлам

Сейчас всё лежит в main.rs. Чтение и запись файла добавят ещё строк семьдесят, и один файл станет неудобным. Поэтому сначала разложим то, что есть:

src/
  main.rs      — цикл и вывод на экран
  task.rs      — что такое задача
  command.rs   — разбор команды
  storage.rs   — чтение и запись файла

Создайте src/task.rs и перенесите туда struct Task вместе с его impl. В src/command.rs перенесите enum Command и функции parse и number. Создайте пустой src/storage.rs — его мы наполним на третьем шаге. В main.rs остаются print_list и main.

Файл нужно объявить

Файл в папке src сам по себе ничего не значит. Чтобы он стал частью программы, его объявляют в main.rs. Добавьте в начало:

// src/main.rs
mod command;
mod storage;
mod task;

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

use command::Command;
use task::Task;

mod task; значит «возьми файл task.rs и подключи его как модуль task». Забудете эту строку — компилятор файла не увидит, даже если тот лежит рядом.

use делает другое. Он ничего не подключает, а только сокращает имя: после use task::Task можно писать Task вместо task::Task.

parse теперь живёт в модуле command, поэтому в цикле вызов пишется так: command::parse(&line).

Всё закрыто, пока не открыто

Соберите:

error[E0603]: struct `Task` is private
 --> src/main.rs:8:11
  |
8 | use task::Task;
  |           ^^^^ private struct
  |
note: the struct `Task` is defined here
 --> src/task.rs:2:1
  |
2 | struct Task {
  | ^^^^^^^^^^^

Такая же ошибка будет про Command и про parse.

В Rust всё, что объявлено в модуле, видно только внутри этого модуля. Пока код был в одном файле, это не мешало. Теперь main.rs — чужой для task.rs, и открыть ему доступ нужно явно, словом pub:

// src/task.rs
#[derive(Debug, PartialEq)]
pub struct Task {
  pub title: String,
  pub done: bool,
}

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

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

pub стоит на структуре, на каждом поле и на каждом методе. Это три отдельных решения: структура может быть публичной, а её поля — нет. Тогда снаружи структуру видно, а внутрь заглянуть нельзя.

В command.rs то же самое:

// src/command.rs
#[derive(Debug, PartialEq)]
pub enum Command {
  Add(String),
  Done(usize),
  Remove(usize),
  List,
  Quit,
  Unknown(String),
}

pub fn parse(line: &str) -> Command {
  // unchanged from part one
}

fn number(rest: &str, make: fn(usize) -> Command) -> Command {
  // unchanged from part one
}

Вариантам enum свой pub не нужен: если enum публичный, публичны и все его варианты.

parse публичная, потому что её вызывает main.rs. number вызывает только parse, из того же файла, поэтому её оставляем закрытой.

Ещё одна новая строка — PartialEq в derive у обоих типов. Она понадобится на пятом шаге, для тестов. Добавьте сейчас, чтобы потом не возвращаться.

Поначалу pub на каждом шагу кажется шумом. Но у него есть обратная сторона: всё, на чём нет pub, — ваше внутреннее дело. Никто снаружи на это не опирается, так что переименовать или переписать такую функцию можно, не проверяя остальной код.

Соберите ещё раз. Программа работает как раньше, только теперь она в четырёх файлах.

Шаг 2. Как задача выглядит в файле

Прежде чем что-то писать на диск, нужно решить, в каком виде.

Можно JSON. Но без библиотек его придётся собирать руками: экранировать кавычки, обратные слэши, переводы строк. Где-нибудь ошибётесь.

Можно разделить поля запятой. Но запятая может оказаться в тексте задачи — и строка развалится.

Мы возьмём самый простой вариант: одна задача — одна строка. В начале флаг 1 или 0 — сделано или нет. Потом табуляция. Потом текст задачи:

1	buy milk
0	walk the cat

Почему это надёжно:

  • Перевода строки в тексте задачи не бывает. Программа читает команду через read_line, а он останавливается на переводе строки.
  • Табуляция в тексте ничего не ломает. Строку мы режем по первой табуляции. Всё, что после неё, — текст задачи, даже если там есть ещё табуляции.

И такой файл можно открыть любым редактором и поправить руками.

Из задачи в строку

Добавьте метод в impl Task:

// src/task.rs, inside impl Task
/// One line of the file: the flag, a tab, the text.
pub fn to_line(&self) -> String {
  let flag = if self.done { '1' } else { '0' };
  format!("{flag}\t{}", self.title)
}

\t — это табуляция. Три слэша /// — комментарий-документация: обычный // виден только в коде, а /// попадёт ещё и в описание, которое строит cargo doc.

Из строки в задачу

Обратное превращение может не получиться: строка в файле может оказаться испорченной. Поэтому метод возвращает Option<Task> — задачу или None:

// src/task.rs, inside impl Task
/// None if the line doesn't look like a task.
pub fn from_line(line: &str) -> Option<Task> {
  let (flag, title) = line.split_once('\t')?;

  Some(Task {
    title: title.to_string(),
    done: flag == "1",
  })
}

Вот он. Вопросительный знак.

split_once вы видели в первой части, там он резал команду по пробелу. Здесь он режет по табуляции и возвращает Option: пару «до» и «после» или None, если табуляции в строке нет.

? после него работает так:

  • если там Some — достань значение и продолжай;
  • если там None — сразу выйди из функции и верни None.

То есть это короткая запись вот такого match:

let (flag, title) = match line.split_once('\t') {
  Some(pair) => pair,
  None => return None,
};

У ? есть одно условие: функция сама должна возвращать Option (или Result — о нём на следующем шаге). Иначе ей нечего вернуть в плохом случае. Если бы from_line возвращал просто Task, компилятор сказал бы:

error[E0277]: the `?` operator can only be used in a method that returns `Result` or `Option` (or another type that implements `FromResidual`)
  --> src/task.rs:24:46
   |
23 |   pub fn from_line(line: &str) -> Task {
   |   ------------------------------------ this function should return `Result` or `Option` to accept `?`
24 |     let (flag, title) = line.split_once('\t')?;
   |                                              ^ cannot use the `?` operator in a method that returns `Task`

В этой версии from_line есть ошибка. На глаз её не видно, поэтому пока оставляем как есть — её найдёт тест на пятом шаге.

Шаг 3. Читаем и пишем файл

Эта часть живёт в storage.rs. Начните файл с импортов:

// src/storage.rs
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};

use crate::task::Task;

use crate::task::Task — это «возьми Task из модуля task нашей программы». crate значит корень программы, то есть main.rs. Из самого main.rs мы писали task::Task. Из соседнего файла путь начинается с crate::.

Пока main не вызывает функции из этого файла, компилятор будет предупреждать о неиспользуемом коде. Это нормально: предупреждения исчезнут на четвёртом шаге, когда мы их подключим.

Чтение, и что такое Result

// src/storage.rs
pub fn load(path: &Path) -> io::Result<Vec<Task>> {
  let text = match fs::read_to_string(path) {
    Ok(text) => text,
    // No file yet is not an error: it is the first run.
    Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
    Err(e) => return Err(e),
  };

  Ok(text.lines().filter_map(Task::from_line).collect())
}

&Path — путь к файлу. Откуда он возьмётся, разберём чуть ниже.

fs::read_to_string читает файл целиком и возвращает Result. Это enum, очень похожий на Option. Только во втором варианте лежит не пустота, а причина неудачи:

enum Result<T, E> {
  Ok(T),  // it worked, here is the value
  Err(E), // it didn't, here is why
}

Ничего больше. Ошибка в Rust — обычное значение, которое функция возвращает.

io::Result<Vec<Task>> в сигнатуре — короткая запись для Result<Vec<Task>, io::Error>. Все операции ввода-вывода возвращают один и тот же тип ошибки, io::Error, поэтому для них есть такое сокращение.

Теперь match по веткам.

Файл прочитался — берём текст.

Файла нет. if после шаблона — дополнительное условие: ветка сработает, только если ошибка именно «файл не найден». И это важное решение: отсутствие файла — не ошибка, а первый запуск. Правильный ответ — пустой список.

Любая другая ошибка — нет прав на чтение, в файле не текст, отказал диск. Исправить это программа не может, поэтому возвращает ошибку тому, кто её вызвал.

Последняя строка читается слева направо:

Ok(text.lines().filter_map(Task::from_line).collect())
  • lines() — пройти по строкам;
  • filter_map(Task::from_line) — превратить каждую строку в задачу, а те, на которых from_line вернул None, выбросить;
  • collect() — собрать результат в Vec<Task>. Какой именно тип собирать, collect понимает из сигнатуры функции.

Испорченные строки пропускаются молча. Файл можно править руками, и одна кривая строка не должна ломать весь список.

Почему ошибка в типе — это хорошо

В большинстве языков с исключениями по сигнатуре функции не видно, может ли она упасть. Это выясняется при запуске, по стектрейсу.

В Rust это написано в типе. io::Result<Vec<Task>> читается как «верну список задач или ошибку ввода-вывода». Чтобы добраться до списка, вам придётся решить, что делать с ошибкой. Промолчать не выйдет — это мы увидим на четвёртом шаге.

Где лежит файл

Самое очевидное — todo.txt в текущей папке. Не надо. Тогда у каждой папки, из которой вы запустили программу, будет свой список: из ~/work — один, из ~/Downloads — другой. И задачи будут «пропадать».

Нужно одно место на всю систему — скрытый файл в домашней папке, ~/.todo.txt:

// src/storage.rs
pub fn path() -> io::Result<PathBuf> {
  match std::env::home_dir() {
    Some(home) => Ok(home.join(".todo.txt")),
    None => Err(io::Error::new(ErrorKind::NotFound, "no home directory")),
  }
}

PathBuf и Path — такая же пара, как String и &str из первой части. PathBuf владеет путём, &Path только смотрит на чужой. Поэтому path() создаёт путь и возвращает PathBuf, а load путь только читает и принимает &Path.

home.join(".todo.txt") добавляет к пути ещё один кусок. Не склеивайте пути через format!: разделитель зависит от системы, а join это учитывает.

home_dir() возвращает Option, потому что домашней папки может не оказаться. Так бывает редко, но бывает. И здесь есть соблазн подставить запасной вариант:

std::env::home_dir().unwrap_or_else(|| PathBuf::from("."))

«Нет домашней папки — возьмём текущую». Выглядит заботливо. Но это та самая проблема, от которой мы только что ушли: список молча переедет туда, откуда запустили программу.

Поэтому честнее отказаться. path() возвращает io::Result<PathBuf>: путь или ошибку с понятным текстом. io::Error::new создаёт такую ошибку — нужно указать её вид и сообщение.

Запись

// src/storage.rs
pub fn save(path: &Path, tasks: &[Task]) -> io::Result<()> {
  let mut text = String::new();
  for task in tasks {
    text.push_str(&task.to_line());
    text.push('\n');
  }

  fs::write(path, text)
}

Собираем все задачи в одну строку и записываем файл целиком. fs::write создаст файл, если его нет, и перезапишет, если есть.

io::Result<()> — «либо ничего, либо ошибка». () — пустое значение, в Rust оно так и пишется. Оно похоже на void из других языков, но это настоящее значение: его можно положить в Ok(()).

Последняя строка без точки с запятой — это то, что функция возвращает. fs::write уже возвращает io::Result<()>, поэтому его результат отдаём как есть.

Шаг 4. Подключаем к программе

У нас есть path, load и save. Осталось вызвать их из main. И заодно разобраться с двумя .unwrap() из первой части:

io::stdout().flush().unwrap();
let read = io::stdin().read_line(&mut line).unwrap();

.unwrap() значит «дай значение, а если там ошибка — аварийно заверши программу». Для черновика годится. Но теперь ошибки станут обычным делом — нет прав на файл, например, — и человек должен увидеть внятное сообщение, а не аварийный вывод.

Все эти вызовы хочется писать через ?. Но ? работает только в функции, которая сама возвращает Result. А main пока не возвращает ничего.

main, который возвращает Result

Так можно:

// src/main.rs
fn main() -> io::Result<()> {
  let path = storage::path()?;
  let mut tasks = storage::load(&path)?;

  loop {
    print!("> ");
    io::stdout().flush()?;
    // ...
  }

  Ok(())
}

Если любой ? получит ошибку, main завершится, а Rust сам напечатает ошибку и выйдет с кодом 1. Проверим: запретим чтение ~/.todo.txt и запустим программу.

Error: Os { code: 13, kind: PermissionDenied, message: "Permission denied" }

Работает. Но посмотрите на это глазами человека, который хотел открыть список дел.

Os { code: 13, kind: PermissionDenied } — отладочное представление ошибки, тот же вывод, что даёт {:?}. Для разработчика годится, для пользователя нет. И главное: не сказано, какой файл. А это единственное, что нужно знать, чтобы пойти и исправить.

Исправим в два шага.

Ошибка знает свой файл

Добавьте в storage.rs небольшую функцию:

// src/storage.rs
/// The same error, with the file name in front of the message.
fn with_path(path: &Path, e: io::Error) -> io::Error {
  io::Error::new(e.kind(), format!("{}: {e}", path.display()))
}

Она создаёт новую ошибку того же вида, но с путём в начале сообщения. {e} в format! — это текст ошибки для человека. {e:?} дал бы то самое Os { code: 13, ... }.

Используйте её в load, в последней ветке:

Err(e) => return Err(with_path(path, e)),

И в save, через map_err:

fs::write(path, text).map_err(|e| with_path(path, e))

map_err значит: «если там ошибка — преобразуй её этой функцией, а успешный результат не трогай».

main показывает ошибку, run делает работу

Перенесите всё тело main в новую функцию run. А в main оставьте только одно: показать ошибку, если она случилась.

// src/main.rs
fn main() {
  if let Err(e) = run() {
    eprintln!("todo: {e}");
    std::process::exit(1);
  }
}

fn run() -> io::Result<()> {
  let path = storage::path()?;
  let mut tasks = storage::load(&path)?;

  let count = tasks.len();
  let word = if count == 1 { "task" } else { "tasks" };
  println!("todo — {count} {word}, {}", path.display());
  println!("commands: add <text>, done <N>, rm <N>, list, quit");

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

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

    // the match over the command — see below
  }

  Ok(())
}

if let Err(e) = run() — это match, в котором нас интересует одна ветка. Если run вернул ошибку, она попадает в e. Если всё хорошо — ничего не происходит.

eprintln! печатает не в обычный вывод, а в поток ошибок. Если кто-то перенаправит вывод программы в файл, ошибки туда не попадут и останутся на экране.

std::process::exit(1) завершает программу с кодом 1. По этому коду другие программы и скрипты понимают, что что-то пошло не так.

Теперь та же ситуация выглядит так:

todo: /Users/jwo1f/.todo.txt: Permission denied (os error 13)

Какой файл и что с ним случилось. Больше ничего и не нужно.

Заодно изменилось приветствие. Теперь при запуске программа говорит, сколько задач загрузила и из какого файла. Путь особенно полезен: из него понятно, что сохранить в бэкап. А word нужен, чтобы не писать «1 tasks».

В настоящем проекте вместо with_path вы бы взяли библиотеку anyhow и написали .context("could not read the task list"). Она делает то же самое, только удобнее. Но у нас ноль зависимостей — зато теперь понятно, зачем она нужна.

Когда сохранять

Первое, что приходит в голову, — при выходе, один раз перед break.

Не надо. Ctrl-C, закрытый терминал, севший ноутбук — и все изменения за сессию потеряны. Ровно то, что мы чиним.

Сохранять надо после каждого изменения. Да, это перезапись всего файла на каждую команду. Но список дел — это пара килобайт, и запись занимает доли миллисекунды.

Как не забыть сохранение ни в одной из веток match? Пусть каждая ветка отвечает на один вопрос: изменился ли список. Ответ — true или false, а сохраняем один раз, после match:

// src/main.rs, inside the loop in run
let changed = match command::parse(&line) {
  Command::Add(title) => {
    if title.is_empty() {
      println!("  add what?");
      false
    } else {
      println!("  added: {title}");
      tasks.push(Task::new(title));
      true
    }
  }
  Command::List => {
    print_list(&tasks);
    false
  }
  // ...the other commands, the same way
  Command::Quit => break,
};

if changed {
  storage::save(&path, &tasks)?;
}

Это тот же приём, что if как выражение из первой части. У ветки нет return: её значение — последнее выражение без точки с запятой. Весь match становится одним значением, и оно записывается в changed.

Ветка Quit значения не возвращает — она выходит из цикла через break. Компилятор это понимает и не требует от неё true или false.

Все ветки целиком — в полном коде в конце статьи.

А теперь обещанное. Что будет, если забыть ? после save:

warning: unused `Result` that must be used
  --> src/main.rs:92:7
   |
92 |       storage::save(&path, &tasks);
   |       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
   |
92 |       let _ = storage::save(&path, &tasks);
   |       +++++++

Result нельзя просто выбросить. Можно явно отказаться от него, написав let _ =, — но это уже осознанное решение, случайно так не напишешь.

Шаг 5. Тесты лежат рядом с кодом

В Rust юнит-тесты пишут в том же файле, что и код, — в конце. Добавьте в конец task.rs:

// src/task.rs, at the end of the file
#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn line_round_trip() {
    let task = Task {
      title: String::from("buy milk"),
      done: true,
    };
    assert_eq!(Task::from_line(&task.to_line()), Some(task));
  }

  #[test]
  fn tabs_are_the_only_separator() {
    let task = Task::from_line("0\tbuy milk, and then\tthe cat").unwrap();
    assert_eq!(task.title, "buy milk, and then\tthe cat");
  }

  #[test]
  fn broken_lines_are_skipped() {
    assert_eq!(Task::from_line("rubbish"), None);
    assert_eq!(Task::from_line("1\t"), None);
    assert_eq!(Task::from_line(""), None);
  }
}

Что здесь что:

  • mod tests { ... } — модуль, объявленный прямо внутри файла. Отдельный файл для модуля не обязателен.
  • #[cfg(test)] — этот модуль собирается только командой cargo test. В обычную программу он не попадёт.
  • use super::* — «возьми всё из модуля уровнем выше», то есть из task.rs. Тестам внутри файла видно даже то, что без pub.
  • #[test] — эта функция и есть тест.
  • assert_eq!(a, b) — проверить, что a равно b. Если нет — тест провален.

Вот зачем был #[derive(Debug, PartialEq)]. PartialEq позволяет assert_eq! сравнить две задачи. Debug позволяет их напечатать, если они не совпали.

.unwrap() в тестах — нормально. Если он сработает на ошибке, тест упадёт, а это ровно то, что нужно.

Первый тест проверяет, что задача, превращённая в строку и обратно, не изменилась. Второй — решение про табуляции со второго шага, записанное в виде проверки. Третий — что испорченные строки не превращаются в задачи.

Запускаем:

cargo test
running 3 tests
test task::tests::tabs_are_the_only_separator ... ok
test task::tests::line_round_trip ... ok
test task::tests::broken_lines_are_skipped ... FAILED

failures:

---- task::tests::broken_lines_are_skipped stdout ----

thread 'task::tests::broken_lines_are_skipped' (5632722) panicked at src/task.rs:55:5:
assertion `left == right` failed
  left: Some(Task { title: "", done: true })
 right: None
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    task::tests::broken_lines_are_skipped

test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--bin todo`

Вот и ошибка, обещанная на втором шаге.

Строка "1\t" — флаг есть, табуляция есть, текста нет. from_line превращал её в задачу с пустым названием. Через программу такую задачу не добавить: на пустой add она отвечает «add what?». Но файл можно править руками, и одна лишняя табуляция — и в списке появится пустой пункт с номером.

Руками такое не найти. Кто станет проверять пустую задачу?

Исправляем from_line: строка без текста — не задача.

// src/task.rs, inside impl Task
pub fn from_line(line: &str) -> Option<Task> {
  let (flag, title) = line.split_once('\t')?;
  if title.is_empty() {
    return None;
  }

  Some(Task {
    title: title.to_string(),
    done: flag == "1",
  })
}

Обратите внимание, сколько сказал упавший тест: что ожидалось, что получилось и в какой строке.

Тесты на хранилище

В конец storage.rs:

// src/storage.rs, at the end of the file
#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn missing_file_is_an_empty_list() {
    let path = std::env::temp_dir().join("todo-test-no-such-file.txt");
    let _ = fs::remove_file(&path);

    assert_eq!(load(&path).unwrap(), Vec::new());
  }

  #[test]
  fn what_is_saved_is_what_is_loaded() {
    let path = std::env::temp_dir().join("todo-test-round-trip.txt");
    let tasks = vec![
      Task::new(String::from("buy milk")),
      Task {
        title: String::from("walk the cat"),
        done: true,
      },
    ];

    save(&path, &tasks).unwrap();
    assert_eq!(load(&path).unwrap(), tasks);

    fs::remove_file(&path).unwrap();
  }
}

Первый тест проверяет решение «нет файла — пустой список». Второй — что сохранённые задачи читаются обратно без изменений.

Оба работают с файлами во временной папке системы, std::env::temp_dir(). Тест не должен трогать ваш настоящий ~/.todo.txt.

let _ = fs::remove_file(&path) — тот самый явный отказ от Result. Файла перед тестом может и не быть, и это нормально: нам нужно только, чтобы его точно не было.

Тесты на команды

В конец command.rs:

// src/command.rs, at the end of the file
#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn add_keeps_the_whole_tail() {
    assert_eq!(
      parse("add buy milk and the cat"),
      Command::Add(String::from("buy milk and the cat"))
    );
  }

  #[test]
  fn empty_line_is_a_list() {
    assert_eq!(parse(""), Command::List);
    assert_eq!(parse("   \n"), Command::List);
  }

  #[test]
  fn short_forms_work() {
    assert_eq!(parse("d 3"), Command::Done(3));
    assert_eq!(parse("a cat"), Command::Add(String::from("cat")));
  }

  #[test]
  fn a_number_that_is_not_a_number() {
    assert!(matches!(parse("done cat"), Command::Unknown(_)));
    assert!(matches!(parse("done -1"), Command::Unknown(_)));
  }
}

matches!(значение, шаблон) возвращает true, если значение подходит под шаблон. Command::Unknown(_) — «любой Unknown, что бы ни лежало внутри». Текст сообщения тест не проверяет, поэтому его можно менять, не ломая тест.

parse("done -1") проверяет случай, родственный багу из первой части. Там done 0 доходил до вычитания единицы и ронял программу. -1 до вычитания не доходит: usize не бывает отрицательным, и разбор числа возвращает ошибку.

Запускаем всё вместе:

running 9 tests
test command::tests::empty_line_is_a_list ... ok
test command::tests::add_keeps_the_whole_tail ... ok
test command::tests::short_forms_work ... ok
test task::tests::broken_lines_are_skipped ... ok
test storage::tests::missing_file_is_an_empty_list ... ok
test command::tests::a_number_that_is_not_a_number ... ok
test task::tests::line_round_trip ... ok
test task::tests::tabs_are_the_only_separator ... ok
test storage::tests::what_is_saved_is_what_is_loaded ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Девять тестов, и никакого тестового фреймворка — всё входит в cargo.

Порядок строк при каждом запуске разный: тесты выполняются параллельно.

Проверяем

cargo run
todo — 0 tasks, /Users/jwo1f/.todo.txt
commands: add <text>, done <N>, rm <N>, list, quit
> add buy milk
  added: buy milk
> add walk the cat
  added: walk the cat
> add write the article
  added: write the article
> done 2
  done: walk the cat
> 
  1. [ ] buy milk
  2. [x] walk the cat
  3. [ ] write the article
> ^D

Выходим через Ctrl-D и запускаем снова:

todo — 3 tasks, /Users/jwo1f/.todo.txt
commands: add <text>, done <N>, rm <N>, list, quit
> 
  1. [ ] buy milk
  2. [x] walk the cat
  3. [ ] write the article
> ^D

Помнит.

А в ~/.todo.txt — ровно то, что мы придумали на втором шаге:

0	buy milk
1	walk the cat
0	write the article

Где это ломается

Программа работает, но у неё есть слабые места. Лучше знать их заранее.

Запись не атомарная. fs::write сначала очищает файл, а потом пишет в него. Если выключить питание между этими моментами, файл останется пустым или обрезанным. Обычно это лечат так: пишут во временный файл рядом, а потом переименовывают его поверх старого через fs::rename. Переименование либо происходит целиком, либо не происходит вовсе. Для списка дел я этот риск принимаю. Для важных данных — нет.

Две запущенные копии перетирают друг друга. Каждая держит свой список в памяти и сохраняет его целиком. Останутся изменения той, что сохранила последней.

Ошибка при сохранении завершает программу. Если диск заполнился, save вернёт ошибку, ? передаст её из run в main, и программа выйдет с сообщением. Последнее изменение на диск не попадёт. Можно было бы показать ошибку и продолжить работу, но тогда человек будет добавлять задачи, которые никуда не сохраняются. Остановиться честнее.

Собираем релиз

Всё, что мы запускали до сих пор, — отладочная сборка: без оптимизаций, с дополнительными проверками. Для работы собирают релизную:

cargo build --release
    Finished `release` profile [optimized] target(s) in 0.42s
ls -lh target/release/todo
-rwxr-xr-x@ 1 jwo1f  wheel   448K Sep 25 12:40 target/release/todo

448 килобайт, один файл. Ему не нужны ни среда выполнения, ни виртуальная машина.

Почти весь этот размер — стандартная библиотека, которая вшита в файл. Для сравнения: Hello, world! из первой части в релизной сборке весит 424 килобайта. Наша программа добавила к нему 24.

Такой файл можно скопировать на другой компьютер с той же операционной системой и тем же типом процессора, и он запустится.

Чтобы программа запускалась из любой папки, установите её:

cargo install --path .

Эта команда соберёт релиз и положит его в ~/.cargo/bin. Эта папка уже есть в PATH — её туда добавил rustup при установке. Теперь достаточно набрать todo.

Весь код

Четыре файла целиком — чтобы сверить со своими.

// src/main.rs
mod command;
mod storage;
mod task;

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

use command::Command;
use task::Task;

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

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

fn main() {
  if let Err(e) = run() {
    eprintln!("todo: {e}");
    std::process::exit(1);
  }
}

fn run() -> io::Result<()> {
  let path = storage::path()?;
  let mut tasks = storage::load(&path)?;

  let count = tasks.len();
  let word = if count == 1 { "task" } else { "tasks" };
  println!("todo — {count} {word}, {}", path.display());
  println!("commands: add <text>, done <N>, rm <N>, list, quit");

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

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

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

    if changed {
      storage::save(&path, &tasks)?;
    }
  }

  Ok(())
}
// src/task.rs
#[derive(Debug, PartialEq)]
pub struct Task {
  pub title: String,
  pub done: bool,
}

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

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

  /// One line of the file: the flag, a tab, the text.
  pub fn to_line(&self) -> String {
    let flag = if self.done { '1' } else { '0' };
    format!("{flag}\t{}", self.title)
  }

  /// None if the line doesn't look like a task.
  pub fn from_line(line: &str) -> Option<Task> {
    let (flag, title) = line.split_once('\t')?;
    if title.is_empty() {
      return None;
    }

    Some(Task {
      title: title.to_string(),
      done: flag == "1",
    })
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn line_round_trip() {
    let task = Task {
      title: String::from("buy milk"),
      done: true,
    };
    assert_eq!(Task::from_line(&task.to_line()), Some(task));
  }

  #[test]
  fn tabs_are_the_only_separator() {
    let task = Task::from_line("0\tbuy milk, and then\tthe cat").unwrap();
    assert_eq!(task.title, "buy milk, and then\tthe cat");
  }

  #[test]
  fn broken_lines_are_skipped() {
    assert_eq!(Task::from_line("rubbish"), None);
    assert_eq!(Task::from_line("1\t"), None);
    assert_eq!(Task::from_line(""), None);
  }
}
// src/command.rs
#[derive(Debug, PartialEq)]
pub enum Command {
  Add(String),
  Done(usize),
  Remove(usize),
  List,
  Quit,
  Unknown(String),
}

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

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

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

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

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn add_keeps_the_whole_tail() {
    assert_eq!(
      parse("add buy milk and the cat"),
      Command::Add(String::from("buy milk and the cat"))
    );
  }

  #[test]
  fn empty_line_is_a_list() {
    assert_eq!(parse(""), Command::List);
    assert_eq!(parse("   \n"), Command::List);
  }

  #[test]
  fn short_forms_work() {
    assert_eq!(parse("d 3"), Command::Done(3));
    assert_eq!(parse("a cat"), Command::Add(String::from("cat")));
  }

  #[test]
  fn a_number_that_is_not_a_number() {
    assert!(matches!(parse("done cat"), Command::Unknown(_)));
    assert!(matches!(parse("done -1"), Command::Unknown(_)));
  }
}
// src/storage.rs
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};

use crate::task::Task;

pub fn path() -> io::Result<PathBuf> {
  match std::env::home_dir() {
    Some(home) => Ok(home.join(".todo.txt")),
    None => Err(io::Error::new(ErrorKind::NotFound, "no home directory")),
  }
}

pub fn load(path: &Path) -> io::Result<Vec<Task>> {
  let text = match fs::read_to_string(path) {
    Ok(text) => text,
    // No file yet is not an error: it is the first run.
    Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
    Err(e) => return Err(with_path(path, e)),
  };

  Ok(text.lines().filter_map(Task::from_line).collect())
}

pub fn save(path: &Path, tasks: &[Task]) -> io::Result<()> {
  let mut text = String::new();
  for task in tasks {
    text.push_str(&task.to_line());
    text.push('\n');
  }

  fs::write(path, text).map_err(|e| with_path(path, e))
}

/// The same error, with the file name in front of the message.
fn with_path(path: &Path, e: io::Error) -> io::Error {
  io::Error::new(e.kind(), format!("{}: {e}", path.display()))
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn missing_file_is_an_empty_list() {
    let path = std::env::temp_dir().join("todo-test-no-such-file.txt");
    let _ = fs::remove_file(&path);

    assert_eq!(load(&path).unwrap(), Vec::new());
  }

  #[test]
  fn what_is_saved_is_what_is_loaded() {
    let path = std::env::temp_dir().join("todo-test-round-trip.txt");
    let tasks = vec![
      Task::new(String::from("buy milk")),
      Task {
        title: String::from("walk the cat"),
        done: true,
      },
    ];

    save(&path, &tasks).unwrap();
    assert_eq!(load(&path).unwrap(), tasks);

    fs::remove_file(&path).unwrap();
  }
}

Что дальше

Мы написали программу без единой зависимости. Специально: так мы разбирались с языком, а не с чужими библиотеками. В настоящих проектах так не делают. Вот что стоит попробовать следующим:

  • serde и serde_json — сохранение данных. to_line и from_line заменяются одной строкой #[derive(Serialize, Deserialize)], и файл становится обычным JSON.
  • anyhow — удобные ошибки в программах. То, что мы делали через with_path и map_err, в одну строку.
  • thiserror — свои типы ошибок, когда вы пишете библиотеку.
  • clap — разбор аргументов командной строки. Учтите: он разбирает аргументы запуска, а наша программа — диалог. С clap она станет такой: todo add buy milk — одна команда, и обратно в терминал. Зато --help он напишет за вас.

Библиотека добавляется одной командой:

cargo add serde --features derive

Что почитать:

  • The Rust Programming Language — официальная книга. Длинная, но лучше неё про язык ничего не написано.
  • Rustlings — набор маленьких сломанных программ, которые нужно починить. Он сам проверяет, получилось ли. Час в день пару недель — и синтаксис перестаёт мешать.
  • Rust by Example — для тех, кому проще читать код, чем прозу.

Всё это и остальное официальное собрано на rust-lang.org.

Напоследок

В первой части программа умела делать то, что просят. Теперь она знает, что делать, когда не получается. Файла нет — это первый запуск. Нет прав — сказать, на какой файл. Строка испорчена — пропустить.

И всё это записано не в голове у автора, а в коде. io::Result в сигнатуре не даёт забыть, что функция может не справиться. ? не даёт замолчать ошибку. Тест не даёт вернуть баг.

Rust не сделает программу правильной за вас. Он только не даст сделать вид, что плохого случая не бывает.

Комментарии 0

Комментариев пока нет.