Перейти к содержимому

Проект: список дел в терминале на Rust

В этом проекте ты напишешь маленький список дел, который живёт в терминале. Пишешь todo add Купить молоко — и он запоминает. Закрываешь терминал, возвращаешься завтра, набираешь todo list — а задачи всё ещё на месте.

Это отличный второй проект, потому что он похож на настоящую утилиту. В нём используются структуры, перечисления, векторы, аргументы командной строки, работа с файлами и Result вместе с ?. Мы соберём каждую деталь отдельно, проверим её в Playground, а потом сложим всё вместе.

Вот как выглядит работа с готовой утилитой:

Terminal window
$ cargo run -- add Купить молоко
Задача 1 добавлена.
$ cargo run -- add Позвонить бабушке
Задача 2 добавлена.
$ cargo run -- done 1
Задача 1 отмечена как выполненная.
$ cargo run -- list
[x] 1: Купить молоко
[ ] 2: Позвонить бабушке
$ cargo run -- remove 1
Задача 1 удалена: Купить молоко

Всё, что стоит после --, передаётся твоей программе, а не Cargo. Задачи хранятся в файле tasks.txt, по одной на строку:

2|0|Позвонить бабушке

Каждая строка устроена как id|done|text, где done равно 1 для выполненной задачи и 0 для невыполненной.

Создай новый проект:

Terminal window
cargo new todo
cd todo

У каждой задачи есть номер (чтобы на неё можно было сослаться), флаг выполнения и текст. Естественный выбор — структура. Дадим ей два метода: один превращает задачу в строку текста для файла, другой читает задачу обратно из строки.

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// Превращает задачу в одну строку вида "1|0|Купить молоко".
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// Читает задачу обратно из строки. Возвращает None, если строка испорчена.
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
fn main() {
let task = Task { id: 1, done: false, text: String::from("Купить молоко") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|Позвонить бабушке"));
println!("{:?}", Task::from_line("3|0|Почитать про A|B-тестирование"));
println!("{:?}", Task::from_line("эта строка испорчена"));
}

Вывод:

1|0|Купить молоко
Some(Task { id: 2, done: true, text: "Позвонить бабушке" })
Some(Task { id: 3, done: false, text: "Почитать про A|B-тестирование" })
None
▶ Попробуй сам

На двух деталях стоит притормозить:

  • splitn(3, '|') делит строку максимум на 3 части. Первые два символа | разделяют поля, а всё, что дальше, — это текст, даже если в нём самом есть |. С обычным split('|') задача «A|B-тестирование» потеряла бы половину текста.
  • from_line возвращает Option и использует ? на каждом шаге. Если какой-то части не хватает или id не число, вся функция возвращает None. .ok() превращает Result от parse() в Option, чтобы на нём сработал ?.

Утилита понимает четыре команды. Каждая из них — вариант перечисления, и некоторые варианты несут данные: Add нужен текст, а Done и Remove — номер задачи.

#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("укажи номер задачи"))?;
arg.parse().map_err(|_| format!("«{arg}» — не номер задачи"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("использование: todo add <текст задачи>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("неизвестная команда «{other}»")),
}
}
fn main() {
let examples = [
vec!["add", "Купить", "молоко"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "два"],
vec!["remove"],
vec!["dance"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

Вывод:

["add", "Купить", "молоко"] -> Ok(Add("Купить молоко"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "два"] -> Err("«два» — не номер задачи")
["remove"] -> Err("укажи номер задачи")
["dance"] -> Err("неизвестная команда «dance»")
▶ Попробуй сам

В настоящей программе args приходят из командной строки. Как ты видел в уроке Аргументы командной строки, первый элемент из std::env::args() — это имя самой программы, поэтому мы его пропускаем:

let args: Vec<String> = std::env::args().skip(1).collect();

Почему add использует args[1..].join(" ")? Оболочка (shell) разбивает todo add Купить молоко на отдельные слова: add, Купить, молоко. Склеивая всё после add, мы собираем текст задачи обратно, и кавычки не нужны.

Обрати внимание: parse_command никогда не падает. Неверный ввод превращается в Err с полезным сообщением, а что с ним делать, решает вызывающий код.

Все задачи живут в Vec<Task>. Каждая команда — это маленькая функция, которая меняет вектор:

  • Add: найти самый большой id, прибавить 1 и добавить новую задачу через push.
  • Done: найти задачу с этим id и поставить done = true.
  • Remove: найти позицию задачи и удалить её.
  • List: напечатать все задачи.
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("задачи {id} нет")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("задачи {id} нет")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Дел нет. Хорошего дня!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn main() {
let mut tasks = Vec::new();
add_task(&mut tasks, String::from("Купить молоко"));
add_task(&mut tasks, String::from("Позвонить бабушке"));
add_task(&mut tasks, String::from("Выучить Rust"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("Удалено: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("Ошибка: {message}");
}
print_tasks(&tasks);
}

Вывод:

Удалено: Купить молоко
Ошибка: задачи 99 нет
[ ] 2: Позвонить бабушке
[x] 3: Выучить Rust
▶ Попробуй сам

Некоторые решения здесь приняты специально:

  • Id берётся от самого большого существующего id, а не из tasks.len() + 1. Если удалить задачу 1 и добавить новую, по длине списка получился бы повторяющийся id.
  • mark_done принимает &mut [Task] (срез), потому что только меняет элементы. add_task и remove_task принимают &mut Vec<Task>, потому что меняют длину.
  • Ошибки имеют тип Result<_, String>. Ошибка-String — самое простое, что работает. Для программ побольше загляни в урок Свои типы ошибок.

iter_mut().find(...) даёт изменяемую ссылку на найденную задачу, поэтому task.done = true меняет задачу прямо внутри вектора. Подробнее о find и position — в уроке Итераторы.

Теперь задачи должны пережить завершение программы. Сохраняем их через std::fs::write, а загружаем через std::fs::read_to_string. Обе операции могут не удаться, поэтому обе возвращают Result, а оператор ? передаёт любую ошибку наверх, вызывающему коду:

use std::fs;
use std::io;
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
// При самом первом запуске файла ещё нет.
// Это не ошибка: просто «задач пока нет».
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}

Случай NotFound важнее, чем кажется. Без него самая первая команда todo add упала бы с ошибкой «No such file or directory» (нет такого файла или каталога), ведь ты ещё ничего не сохранял. Все остальные ошибки (например, «permission denied» — нет доступа) по-прежнему передаются наверх.

filter_map(Task::from_line) читает каждую строку и оставляет только те, что удалось разобрать. Учти: испорченные строки так тихо пропускаются. Это удобно, если кто-то правит файл руками и опечатается, но и ошибка может остаться незамеченной. Одна из идей для развития ниже — выводить о них предупреждение.

Больше о чтении и записи файлов — в уроке Работа с файлами.

Вот всё вместе. Положи это в src/main.rs и запускай командами cargo run -- add Купить молоко, cargo run -- list и так далее.

Основная логика находится в run_command: она работает с Vec<Task> и ничего не знает о файлах. main только загружает файл, вызывает run_command и сохраняет результат. Когда эти части разделены, логику легко тестировать — и именно это позволит нам сделать версию для Playground на следующем шаге.

use std::env;
use std::error::Error;
use std::fs;
use std::io;
use std::process;
const FILE: &str = "tasks.txt";
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("укажи номер задачи"))?;
arg.parse().map_err(|_| format!("«{arg}» — не номер задачи"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("использование: todo add <текст задачи>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("неизвестная команда «{other}»")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("задачи {id} нет")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("задачи {id} нет")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Дел нет. Хорошего дня!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("Задача {id} добавлена.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Задача {id} отмечена как выполненная.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Задача {id} удалена: {}", task.text);
}
}
Ok(())
}
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}
fn run() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().skip(1).collect();
let mut tasks = load_tasks(FILE)?;
run_command(&args, &mut tasks)?;
save_tasks(FILE, &tasks)?;
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("Ошибка: {e}");
process::exit(1);
}
}

Пара слов о run() -> Result<(), Box<dyn Error>>. Наши функции возвращают ошибки двух разных типов: io::Error из файловых функций и String из функций для команд. Box<dyn Error> — это коробка, в которую помещается любой тип ошибки, поэтому ? работает с обоими. Затем main печатает ошибку через eprintln! (он пишет в поток ошибок) и завершается с кодом 1 — обычный сигнал, что что-то пошло не так.

Шаг 6: версия, которую можно запустить в Playground

Заголовок раздела «Шаг 6: версия, которую можно запустить в Playground»

Playground не умеет передавать аргументы командной строки, а зависеть от файлов нам не хочется. Поэтому эта версия оставляет ту же run_command, но скармливает ей заранее заготовленный список команд и в конце печатает, что оказалось бы в tasks.txt:

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("укажи номер задачи"))?;
arg.parse().map_err(|_| format!("«{arg}» — не номер задачи"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("использование: todo add <текст задачи>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("неизвестная команда «{other}»")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("задачи {id} нет")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("задачи {id} нет")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Дел нет. Хорошего дня!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("Задача {id} добавлена.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Задача {id} отмечена как выполненная.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Задача {id} удалена: {}", task.text);
}
}
Ok(())
}
fn main() {
// Каждая строка — то, что ты набрал бы после `todo`.
let script = [
"list",
"add Купить молоко",
"add Позвонить бабушке",
"add Выучить Rust",
"done 3",
"remove 1",
"done 9",
"jump",
"list",
];
let mut tasks: Vec<Task> = Vec::new();
for line in script {
println!("$ todo {line}");
let args: Vec<String> = line.split_whitespace().map(String::from).collect();
if let Err(message) = run_command(&args, &mut tasks) {
println!("Ошибка: {message}");
}
}
println!();
println!("В tasks.txt окажется:");
for task in &tasks {
println!("{}", task.to_line());
}
}

Вывод:

$ todo list
Дел нет. Хорошего дня!
$ todo add Купить молоко
Задача 1 добавлена.
$ todo add Позвонить бабушке
Задача 2 добавлена.
$ todo add Выучить Rust
Задача 3 добавлена.
$ todo done 3
Задача 3 отмечена как выполненная.
$ todo remove 1
Задача 1 удалена: Купить молоко
$ todo done 9
Ошибка: задачи 9 нет
$ todo jump
Ошибка: неизвестная команда «jump»
$ todo list
[ ] 2: Позвонить бабушке
[x] 3: Выучить Rust
В tasks.txt окажется:
2|0|Позвонить бабушке
3|1|Выучить Rust
▶ Попробуй сам

Измени сценарий и запусти снова. Попробуй добавить задачу после удаления другой и проверь, что новый id не повторяется.

Наш формат id|done|text легко читать, но to_line и from_line пришлось писать руками. Когда задачи обрастут подробностями (сроки, приоритеты, метки), этот ручной код тоже будет расти.

В настоящих проектах эту работу обычно поручают крейтам serde (на английском). Добавь их так:

Terminal window
cargo add serde --features derive
cargo add serde_json

После этого одна строка derive даёт сохранение и загрузку бесплатно:

use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn main() {
let tasks = vec![
Task { id: 1, done: false, text: String::from("Купить молоко") },
Task { id: 2, done: true, text: String::from("Позвонить бабушке") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("Загружено задач: {}", loaded.len());
}

Вывод:

[{"id":1,"done":false,"text":"Купить молоко"},{"id":2,"done":true,"text":"Позвонить бабушке"}]
Загружено задач: 2
▶ Попробуй сам

Эту JSON-строку ты записал бы в tasks.json через fs::write, точно как раньше. Подробно об этом — в уроке JSON и serde.

Использовать ? в main, которая ничего не возвращает. Так и тянет написать:

use std::fs;
fn main() {
let text = fs::read_to_string("tasks.txt")?;
println!("{text}");
}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:47
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let text = fs::read_to_string("tasks.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`

Компилятор говорит, что оператор ? можно использовать только в функции, которая возвращает Result или Option. ? означает «если здесь ошибка, верни её из текущей функции». Обычная main возвращает (), так что ошибку некуда отправить. Либо дай main возвращаемый тип вроде Result<(), Box<dyn Error>>, либо сделай как в нашей программе: вынеси работу в функцию run(), которая возвращает Result, и обработай ошибку один раз в main.

Считать отсутствие файла сбоем. Если бы load_tasks просто делала fs::read_to_string(path)?, программа падала бы при самом первом запуске, когда файла ещё нет. Проверь io::ErrorKind::NotFound и в этом случае начни с пустого списка.

Брать длину списка как следующий id. Были задачи 1, 2, 3; удалили задачу 1 — осталось две. len() + 1 снова даст 3, и у двух задач окажется один id. Бери самый большой id плюс один.

  • Отмена «done». Добавь команду undone <id>, которая возвращает done в false.
  • Редактирование задачи. edit 2 Позвонить бабушке вечером заменяет текст задачи 2.
  • Удаление выполненных. clear удаляет все выполненные задачи. Посмотри на Vec::retain (на английском).
  • Предупреждения об испорченных строках. Вместо того чтобы молча пропускать плохие строки, печатай предупреждение с номером строки.
  • Команда help, которая выводит все команды и как ими пользоваться.
  • Тесты. parse_command и run_command не трогают файлы, поэтому их легко тестировать.
  • Настоящий парсер аргументов. Крейт clap (на английском), о котором шла речь в уроке Аргументы командной строки, бесплатно даёт --help и подкоманды.
  • Структура Task хранит данные. to_line и from_line превращают её в текст и обратно.
  • Перечисление Command описывает, что попросил пользователь. parse_command превращает аргументы в Command или в Err с понятным сообщением.
  • Vec<Task> хранит список. Маленькие функции добавляют, находят, меняют и удаляют задачи.
  • fs::read_to_string и fs::write возвращают Result, а ? передаёт ошибки наверх. Отсутствие файла при первом запуске обрабатывается как «задач нет».
  • Когда логика (run_command) отделена от файлов и аргументов, её легко тестировать и запускать в Playground.

Проверь себя

1. Почему from_line использует splitn(3, '|'), а не split('|')?

2. Что делает load_tasks, если tasks.txt ещё не существует?

3. Почему нельзя использовать ? прямо внутри fn main() без возвращаемого типа?

4. Какой крейт обычно берут, чтобы сохранять структуры в JSON?

Результат: 0 / 4