Создание CLI для отслеживания задач

RustBeginner
Практиковаться сейчас

Введение

Полезное приложение командной строки объединяет несколько уровней: типизированные аргументы, данные предметной области, постоянное хранилище, понятный вывод, обрабатываемые ошибки и автоматические проверки. Если создавать всё с нуля, архитектура затеряется за большим объёмом ручного ввода.

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

Изучите структуру проекта и интерфейс команд

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

Перейдите в каталог проекта и выведите список исходных файлов:

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, проходит целевые и сквозные библиотечные тесты, соответствует требованиям цикла проверки и создаёт релизный исполняемый файл с зафиксированными зависимостями.