Проект: список дел в терминале на Rust
В этом проекте ты напишешь маленький список дел, который живёт в терминале. Пишешь todo add Купить молоко — и он запоминает. Закрываешь терминал, возвращаешься завтра, набираешь todo list — а задачи всё ещё на месте.
Это отличный второй проект, потому что он похож на настоящую утилиту. В нём используются структуры, перечисления, векторы, аргументы командной строки, работа с файлами и Result вместе с ?. Мы соберём каждую деталь отдельно, проверим её в Playground, а потом сложим всё вместе.
Что у нас получится
Заголовок раздела «Что у нас получится»Вот как выглядит работа с готовой утилитой:
$ 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 для невыполненной.
Создай новый проект:
cargo new todocd todoШаг 1: структура Task
Заголовок раздела «Шаг 1: структура Task»У каждой задачи есть номер (чтобы на неё можно было сослаться), флаг выполнения и текст. Естественный выбор — структура. Дадим ей два метода: один превращает задачу в строку текста для файла, другой читает задачу обратно из строки.
#[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, чтобы на нём сработал?.
Шаг 2: перечисление Command
Заголовок раздела «Шаг 2: перечисление Command»Утилита понимает четыре команды. Каждая из них — вариант перечисления, и некоторые варианты несут данные: 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 с полезным сообщением, а что с ним делать, решает вызывающий код.
Шаг 3: работаем со списком задач
Заголовок раздела «Шаг 3: работаем со списком задач»Все задачи живут в 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 — в уроке Итераторы.
Шаг 4: сохраняем и загружаем файл
Заголовок раздела «Шаг 4: сохраняем и загружаем файл»Теперь задачи должны пережить завершение программы. Сохраняем их через 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) читает каждую строку и оставляет только те, что удалось разобрать. Учти: испорченные строки так тихо пропускаются. Это удобно, если кто-то правит файл руками и опечатается, но и ошибка может остаться незамеченной. Одна из идей для развития ниже — выводить о них предупреждение.
Больше о чтении и записи файлов — в уроке Работа с файлами.
Шаг 5: программа целиком
Заголовок раздела «Шаг 5: программа целиком»Вот всё вместе. Положи это в 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 не повторяется.
Улучшение: сохраняем в JSON с помощью serde
Заголовок раздела «Улучшение: сохраняем в JSON с помощью serde»Наш формат id|done|text легко читать, но to_line и from_line пришлось писать руками. Когда задачи обрастут подробностями (сроки, приоритеты, метки), этот ручной код тоже будет расти.
В настоящих проектах эту работу обычно поручают крейтам serde (на английском). Добавь их так:
cargo add serde --features derivecargo 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
Что почитать
Заголовок раздела «Что почитать»- Проект с вводом-выводом: создание программы командной строки — книга «Язык программирования Rust» (перевод на русский): проект побольше, тоже консольный, с хорошими советами, как разбить
mainна маленькие части, которые легко тестировать. std::fs— документация std (на английском):read_to_string,writeи другие файловые функции, на которых построены сохранение и загрузка.std::env::args— документация std (на английском): как приходят аргументы командной строки и почему первый из них — имя самой программы.str::splitn— документация std (на английском): деление строки максимум на N частей. Именно этот приём сохраняет текст задачи с|целым.serde_json— docs.rs (на английском): JSON-крейт из раздела про улучшение, с примерамиto_stringиfrom_str. Атрибуты derive объяснены в руководстве по serde (на английском).