Введение
Полезное приложение командной строки объединяет несколько уровней: типизированные аргументы, данные предметной области, постоянное хранилище, понятный вывод, обрабатываемые ошибки и автоматические проверки. Если создавать всё с нуля, архитектура затеряется за большим объёмом ручного ввода.
В этой лабораторной работе вам предоставлен полный каркас трекера задач. Сначала вы изучите карту модулей, а затем по очереди реализуете небольшие части: чтение и сохранение данных, добавление задач, просмотр списка, отметку задач как выполненных и создание проверенной релизной сборки. Вам не придётся вставлять целые исходные файлы.
Изучите структуру проекта и интерфейс команд
На этом шаге вы разберётесь в подготовленном проекте и свяжете его файлы с потоком данных, прежде чем изменять код.
Перейдите в каталог проекта и выведите список исходных файлов:
cd /home/labex/project/tasker
ls src
lib.rs main.rs model.rs store.rs
Каждый файл отвечает в основном за одну задачу:
main.rsотвечает за границу процесса: разбирает аргументы, выводит сообщения об успехе и сообщает об ошибках;lib.rsсодержит операции над задачами, которые вызывают CLI и тесты;model.rsопределяет структуруTaskи преобразует её в строку хранилища и обратно;store.rsчитает и записывает коллекцию задач.
Такое разделение не позволяет объединить разбор аргументов, операции предметной области и работу с файлами в одну большую функцию. Подготовленный проект уже содержит объявления модулей и более объёмный разборщик модели; вам нужно будет реализовать только отмеченные границы операций.
Изучите точку входа CLI:
nano src/main.rs
Атрибут #[command(subcommand)] сообщает clap, что следующее слово команды выбирает вариант перечисления Commands. Для параметра --file указан признак global = true, поэтому пользователь может разместить его до или после подкоманды. Значение типа PathBuf по умолчанию равно tasks.db. Нажмите Ctrl+X, не изменяя файл.
Выведите общую справку:
cargo run --quiet -- --help
В справке перечислены команды add, list и done. Запросите подробную справку для add:
cargo run --quiet -- add --help
Обязательное значение <TITLE> берётся из поля title: String варианта Add. Интерфейс уже готов; на следующих шагах вы реализуете библиотечные операции для каждой команды.
Завершите работу с хранилищем
На этом шаге вы завершите два небольших преобразования, которые связывают значения задач с локальным текстовым файлом.
Откройте подготовленный модуль хранилища:
nano src/store.rs
Формат файла предусматривает одну задачу в каждой строке и три поля, разделённые символом табуляции:
id<TAB>status<TAB>title
В model.rs уже определены методы Task::encode и Task::decode. Модулю хранилища нужно только применить эти вспомогательные методы ко всей коллекции.
Замените TODO загрузки и расположенные ниже две строки следующим кодом:
let tasks = contents
.lines()
.filter(|line| !line.is_empty())
.map(Task::decode)
.collect::<Result<Vec<_>, _>>()?;
Ok(tasks)
Итератор преобразует каждую непустую строку в Result<Task, String>. Сбор в Result<Vec<_>, _> останавливается на первой некорректной строке или возвращает все декодированные задачи. Оператор вопросительного знака передаёт эту ошибку из load.
В save замените его TODO и последние три строки следующим кодом:
fs::write(path, contents)
.map_err(|error| format!("could not write {}: {error}", path.display()))
fs::write создаёт файл хранилища или заменяет его содержимое. map_err добавляет путь, при работе с которым произошла ошибка, сохраняя возможность обработать её через Result.
Сохраните файл и выйдите из nano. Запустите только целевой тест хранилища:
cargo test store::tests::saves_and_loads_tasks
Один успешно завершившийся тест подтверждает, что коллекция задач может пройти через файловую границу и вернуться в виде равных значений Rust.
Добавьте и сохраните новые задачи
На этом шаге вы реализуете библиотечную операцию для подкоманды add.
Откройте точку входа библиотеки:
nano src/lib.rs
Замените TODO и тело-заглушку функции add_task следующим кодом:
let mut tasks = store::load(path)?;
let next_id = tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1;
let task = Task::new(next_id, title);
tasks.push(task.clone());
store::save(path, &tasks)?;
Ok(task)
Сначала операция загружает текущее состояние. Выражение max().unwrap_or(0) + 1 выдаёт ID 1 для пустого файла, а в остальных случаях — число на единицу больше максимального существующего ID. Задача клонируется один раз: одна принадлежащая программе копия добавляется в вектор, а возвращаемая копия позволяет CLI описать добавленную задачу.
Сохраните файл и выйдите из nano. Добавьте две задачи в отдельный демонстрационный файл:
cargo run --quiet -- --file add-demo.db add "Write release notes"
Added 1: Write release notes
cargo run --quiet -- --file add-demo.db add "Tag version"
Added 2: Tag version
Второй ID подтверждает, что команда загрузила первую запись, прежде чем выбрать и сохранить следующий ID.
Отформатируйте список задач
На этом шаге вы преобразуете сохранённые задачи в стабильный, удобный для чтения вывод команды.
Снова откройте src/lib.rs:
nano src/lib.rs
Замените TODO и тело-заглушку функции list_tasks следующим кодом:
let tasks = store::load(path)?;
Ok(tasks
.iter()
.map(|task| {
let marker = if task.done { "x" } else { " " };
format!("[{marker}] {}: {}", task.id, task.title)
})
.collect())
Маркер компактно показывает состояние задачи: [ ] означает, что задача открыта, а [x] — что она выполнена. Функция возвращает строки для отображения, а не печатает их, поэтому тесты и другие вызывающие функции могут проверить результат без перехвата вывода терминала.
Сохраните файл и выйдите из nano. Используйте файл, созданный на предыдущем шаге:
cargo run --quiet -- --file add-demo.db list
[ ] 1: Write release notes
[ ] 2: Tag version
Библиотека отвечает за форматирование строк задач, а main.rs занимается только выводом возвращённых строк.
Отметьте одну задачу как выполненную
На этом шаге вы обновите одну задачу, сохранив остальные записи коллекции.
Откройте исходный файл библиотеки:
nano src/lib.rs
Замените TODO и тело-заглушку функции complete_task следующим кодом:
let mut tasks = store::load(path)?;
let task = tasks
.iter_mut()
.find(|task| task.id == id)
.ok_or_else(|| format!("task {id} was not found"))?;
task.done = true;
let completed = task.clone();
store::save(path, &tasks)?;
Ok(completed)
iter_mut() предоставляет изменяемые ссылки, поэтому подходящую запись можно изменить непосредственно в векторе. Если ID отсутствует, find возвращает None; ok_or_else преобразует это отсутствие в понятную ошибку функции. Выполненная задача клонируется до сохранения, поскольку изменяемое заимствование принадлежит вектору, который будет сохранён.
Сохраните файл и выйдите из nano. Отметьте задачу 1 как выполненную в демонстрационном файле:
cargo run --quiet -- --file add-demo.db done 1
Completed 1: Write release notes
Снова выведите сохранённое состояние:
cargo run --quiet -- --file add-demo.db list
[x] 1: Write release notes
[ ] 2: Tag version
Попробуйте указать отсутствующий ID:
cargo run --quiet -- --file add-demo.db done 99
Эта команда должна завершиться ошибкой. Сообщение выводится в stderr, а процесс завершается с ненулевым кодом, потому что main.rs уже преобразует Err на границе процесса.
Проверьте проект и соберите релизную версию
На этом шаге вы выполните стандартный цикл проверки перед передачей проекта и создадите релизный исполняемый файл.
Сначала отформатируйте изменения, внесённые в модули хранилища и библиотеки:
cargo fmt
Убедитесь, что форматтер не предлагает дополнительных изменений:
cargo fmt -- --check
Запустите строгую проверку Clippy:
cargo clippy -- -D warnings
Запустите весь набор тестов:
cargo test
Должны пройти два теста: целевой тест полного цикла сохранения и загрузки хранилища, а также библиотечный сценарий add-list-done целиком. Эти тесты используют временные файлы, поэтому проверяют настоящее сохранение данных и не зависят от вашей демонстрационной базы данных.
Соберите оптимизированную релизную версию:
cargo build --release --locked
Параметр --release выбирает оптимизированный релизный профиль Cargo вместо профиля разработки, который компилируется быстрее. Параметр --locked требует точно тот граф зависимостей, который уже записан в Cargo.lock. Запустите полученный исполняемый файл напрямую:
./target/release/tasker --file release-demo.db add "Publish tasker"
Added 1: Publish tasker
./target/release/tasker --file release-demo.db list
[ ] 1: Publish tasker
Прямой путь подтверждает, что вы запускаете собранный артефакт, а не просите Cargo заново скомпилировать и запустить программу.
Итоги
Вы завершили многооконный CLI на Rust, не перепечатывая его архитектуру. Готовый проект разбирает типизированные подкоманды с помощью clap, сохраняет модели задач через отдельный модуль хранилища, передаёт ошибки с контекстом, оставляет вывод процесса в main, проходит целевые и сквозные библиотечные тесты, соответствует требованиям цикла проверки и создаёт релизный исполняемый файл с зафиксированными зависимостями.


