$ sudo teach IT
Модуль 7 · Проект целиком · Урок 7.4

Финал: копилка готова

Вы соберёте релизную версию «Копилки», наведёте порядок в коде и поймёте, как устроен весь проект и куда расти дальше.

Практикум~30 минутСредний

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

Релизная сборка

Всё это время вы запускали проект через cargo run. Cargo при этом собирал «черновик»: быстро, с запасом отладочной информации и без оптимизаций. Это как репетиция спектакля в рабочей одежде: можно остановиться, заглянуть за кулисы, поправить. Для премьеры нужна другая сборка: отточенная, быстрая и компактная, но готовится она дольше.

Официально это называется профилем сборки (profile). У Cargo их два основных: dev (по умолчанию, его называют debug) и release. Чтобы собрать релизный, добавьте флаг --release:

Терминал
cargo build --release
Вывод
   Compiling kopilka v0.1.0 (/home/user/kopilka)
    Finished `release` profile [optimized] target(s) in 0.24s

Время у вас будет другим: на чистой сборке оно заметно больше. Готовая программа лежит не в корне проекта, а в папке target/release/: файл kopilka (на Windows — kopilka.exe). Папку target/ создаёт Cargo, в ней же лежит и черновая сборка в target/debug/.

Разбор по строкам

  • cargo build — собрать проект, но не запускать (в отличие от cargo run).
  • --release — флаг профиля: включить оптимизации. Без него собирается debug.
  • Compiling kopilka — компилятор собирает ваш пакет; в скобках путь к проекту.
  • Finished `release` profile [optimized] — сборка успешна, профиль release, оптимизации включены.

Чем отличаются две сборки

debug (по умолчанию)release
Командаcargo build или cargo runcargo build --release или cargo run --release
Папкаtarget/debug/target/release/
Сборкабыстраядольше, компилятор оптимизирует
Скорость программынижевыше
Размер файла (у автора на macOS)около 710 КБоколо 465 КБ

Готовый файл можно скопировать на другой компьютер с такой же системой, и Rust там устанавливать не нужно: программа самостоятельная.

Ловушка: переполнение в release молчит

В debug-сборке переполнение целого числа (скажем, u32 перевалил за свой максимум) заканчивается паникой, а в release оно по умолчанию не проверяется и число «заворачивается» вокруг. Поэтому тестируйте и черновую, и релизную сборку, а границы проверяйте сами, как это делает MAX_AMOUNT в «Копилке».

А если запустить двойным кликом?

Файл можно открыть и из файлового менеджера, но папка, в которой в этот момент «находится» программа, зависит от системы и способа запуска. Надёжнее запускать из терминала, находясь там, где должен лежать kopilka.txt: имя файла в программе относительное, и Rust ищет его в текущей папке.

Порядок в коде: cargo fmt и cargo clippy

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

Официально первый называется rustfmt (запуск — cargo fmt), второй — Clippy (cargo clippy). Оба ставятся вместе с Rust через rustup. Если команда не находится, выполните rustup component add rustfmt clippy.

cargo fmt: единый вид

Допустим, в report.rs функция total записана небрежно:

Rust · report.rs
pub fn total(expenses:&[Expense])->u32{ expenses.iter().map(|e| e.amount).sum::<u32>() }

Команда cargo fmt --check ничего не меняет, а только показывает, что именно переписал бы форматтер:

Терминал
cargo fmt --check
Вывод
Diff in /home/user/kopilka/src/report.rs:3:
 use crate::MONTH_LIMIT;
 use crate::expense::{Category, Expense};
 
-pub fn total(expenses:&[Expense])->u32{ expenses.iter().map(|e| e.amount).sum::<u32>() }
+pub fn total(expenses: &[Expense]) -> u32 {
+    expenses.iter().map(|e| e.amount).sum::<u32>()
+}
 
 pub fn totals_by_category(expenses: &[Expense]) -> HashMap<Category, u32> {
     let mut totals: HashMap<Category, u32> = HashMap::new();

Разбор по строкам

  • Diff in .../report.rs:3 — файл и строка, с которой начинаются расхождения.
  • Строки с - — как написано сейчас, строки с + — как будет после форматирования; строки с пробелом в начале не меняются.
  • Если расхождений нет, команда молчит и завершается успешно; при расхождениях она завершается с ошибкой, и это удобно для проверок.

Сам cargo fmt без --check перепишет файлы. Он меняет только вид кода, а не поведение программы.

cargo clippy: подсказки напарника

Допустим, вместо expenses.is_empty() в print_list написано expenses.len() == 0. Программа работает, но можно проще:

Терминал
cargo clippy
Вывод
    Checking kopilka v0.1.0 (/home/user/kopilka)
warning: length comparison to zero
  --> src/report.rs:29:8
   |
29 |     if expenses.len() == 0 {
   |        ^^^^^^^^^^^^^^^^^^^ help: using `is_empty` is clearer and more explicit: `expenses.is_empty()`
   |
   = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#len_zero
   = note: `#[warn(clippy::len_zero)]` on by default

warning: `kopilka` (bin "kopilka") generated 1 warning (run `cargo clippy --fix --bin "kopilka" -p kopilka -- ` to apply 1 suggestion)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s

Разбор по строкам

  • warning: length comparison to zero — суть замечания: длину сравнивают с нулём.
  • --> src/report.rs:29:8 — файл, строка и столбец.
  • help: ... expenses.is_empty() — готовая замена.
  • #[warn(clippy::len_zero)] — имя правила; по нему можно найти объяснение по ссылке выше.

Предупреждение не ошибка

Слово warning означает «можно лучше», а не «сломано»: сборка проходит, программа запускается. Прочитайте совет и решите сами. Команда cargo clippy --fix умеет применять простые советы автоматически, но сначала поймите, что она изменит. На вашем готовом проекте cargo clippy печатает только Checking и Finished: тишина здесь — хороший знак.

Карта проекта

Когда проект вырос до пяти файлов, полезно держать в голове «план дома»: что где лежит и зачем. Вот он (в скобках — где вы эту тему проходили):

ФайлЧто внутриОткуда
main.rsподключение модулей, MONTH_LIMIT, print_help, run_command, главный цикл mainциклы, ввод, match
expense.rsCategory (from_word, title), Expense (new, describe, to_line, from_line)структуры и перечисления
command.rsCommand, CommandError с Display, parse_command, тесты разбораошибки: Result и ?
report.rstotal, totals_by_category, print_status, print_list, print_report, тестывекторы, HashMap, итераторы
storage.rssave и load: чтение и запись kopilka.txtstd::fs и io::Result

Путь одной команды

Проследите, что происходит, когда вы набираете добавить 350 еда обед:

  1. main читает строку через read_line в переменную line (заимствование и строки).
  2. parse_command из command.rs превращает строку в Ok(Command::Add(expense)) или в понятную ошибку CommandError.
  3. main отдаёт команду в run_command, а та через match выбирает действие.
  4. Ветка Command::Add кладёт трату в вектор, вызывает save из storage.rs и печатает статус через print_status из report.rs.

А вот целиком main.rs. Остальные файлы вы уже знаете по прошлым урокам, а этот связывает их воедино. Лежит он в папке src рядом с остальными:

Rust · main.rs
mod command;
mod expense;
mod report;
mod storage;

use std::io;

use command::{Command, parse_command};
use expense::Expense;
use report::{print_list, print_report, print_status, total};
use storage::{load, save};

pub const MONTH_LIMIT: u32 = 30_000;
const DATA_FILE: &str = "kopilka.txt";

fn print_help() {
    println!("Команды:");
    println!("  добавить <сумма> <категория> [заметка] - записать трату");
    println!("  список - показать все траты");
    println!("  отчёт - итоги по категориям");
    println!("  помощь - показать эту подсказку");
    println!("  выход - закончить работу");
    println!("Категории: еда, транспорт, дом, отдых, другое");
}

fn run_command(command: Command, expenses: &mut Vec<Expense>) -> bool {
    match command {
        Command::Add(expense) => {
            println!("Записано: {}", expense.describe());
            expenses.push(expense);
            if let Err(err) = save(DATA_FILE, expenses) {
                println!("Не удалось сохранить: {err}");
            }
            let spent = total(expenses);
            println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
            print_status(spent, MONTH_LIMIT);
        }
        Command::List => print_list(expenses),
        Command::Report => print_report(expenses),
        Command::Help => print_help(),
        Command::Quit => return false,
    }
    true
}

fn main() {
    println!("Копилка 0.7");
    println!("Наберите помощь, чтобы увидеть команды.");

    let mut expenses = match load(DATA_FILE) {
        Ok(loaded) => loaded,
        Err(err) => {
            println!("Не удалось прочитать {DATA_FILE}: {err}");
            Vec::new()
        }
    };
    if !expenses.is_empty() {
        println!("Загружено трат: {}", expenses.len());
    }
    let mut line = String::new();

    loop {
        line.clear();
        let bytes = io::stdin()
            .read_line(&mut line)
            .expect("не удалось прочитать строку");
        if bytes == 0 {
            break;
        }

        match parse_command(&line) {
            Ok(command) => {
                if !run_command(command, &mut expenses) {
                    break;
                }
            }
            Err(err) => println!("Ошибка: {err}"),
        }
    }

    println!(
        "Итого потрачено: {} руб. из {MONTH_LIMIT} руб.",
        total(&expenses)
    );
}

Разбор по строкам

  • mod command; и ещё три строки — подключают файлы-модули; без них Rust не узнает, что они есть.
  • use ... — приносят нужные имена из модулей, чтобы писать parse_command, а не command::parse_command.
  • pub const MONTH_LIMIT — лимит месяца, видный и из report.rs (use crate::MONTH_LIMIT); DATA_FILE — имя файла, относительное к текущей папке.
  • print_help — печатает список команд; run_command — выполняет одну разобранную команду и возвращает false только для Quit.
  • let mut expenses = match load(DATA_FILE) — при старте загружаем траты, а при ошибке чтения печатаем причину и начинаем с пустого вектора.
  • цикл loop: line.clear() очищает буфер, read_line возвращает 0 байт на конце ввода (тогда break), дальше parse_command и match по Ok / Err.
  • последний println! — итог при выходе.

Что можно добавить самим

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

  • Команда удалить N. Нужен новый вариант в Command, разбор номера в parse_command (с понятной ошибкой, как у суммы), ветка в run_command и сохранение файла после удаления. Помните, что в списке номера начинаются с 1, а в векторе — с 0, и что выход за границы вектора ведёт к панике: метод удаления найдите в документации Vec по ссылкам из Книги.
  • Лимит по категориям. Функция с match по Category, которая возвращает свой лимит для каждой категории; print_report может помечать категории, где он превышен. Заодно напишете для неё тест в report.rs.
  • Месяц в строке файла. Строка вида 2026-11;350;еда;обед позволит считать итоги по месяцам. Сложность в том, что старые файлы со строкой из трёх частей не должны ломаться: from_line придётся научить читать оба вида, а тесты — проверять оба.

Каждую правку начинайте с теста, а заканчивайте прогоном fmt, clippy и test.

Куда дальше

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

  • Курс Rust на sudoteach.com — обобщения, трейты, времена жизни, умные указатели, потоки и итоговый веб-сервер.
  • Курс Cargo на sudoteach.com — подробно про Cargo: подключение крейтов (готовых библиотек), настройка профилей, workspace для проектов из нескольких пакетов.
  • Книга «The Rust Programming Language» — официальная и бесплатная; к ней удобно возвращаться за подробностями по любой теме курса.

Практика: финальный прогон

Берём проект kopilka в том виде, в каком он остался после урока про тесты (пять файлов в src). Код менять не нужно: мы проверим его и соберём.

Шаг 1. Чистый старт

Откройте терминал в папке проекта (там, где лежит Cargo.toml). Если от прошлых запусков остался kopilka.txt, удалите его (rm kopilka.txt, в Windows — del kopilka.txt), чтобы сценарий шёл с нуля.

Шаг 2. Порядок и тесты

Запустите cargo fmt --check (молчит) и cargo clippy (только Checking и Finished), затем cargo test:

Вывод
running 10 tests
test command::tests::parse_empty_line ... ok
test command::tests::parse_add_with_note ... ok
test command::tests::parse_bad_amount ... ok
test command::tests::parse_add_without_note ... ok
test command::tests::parse_missing_args_and_category ... ok
test command::tests::parse_simple_commands ... ok
test report::tests::total_of_empty_list_is_zero ... ok
test command::tests::parse_unknown_command ... ok
test report::tests::total_sums_all_amounts ... ok
test report::tests::totals_group_by_category ... ok

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

Шаг 3. Релиз

Соберите релизную версию командой cargo build --release (вывод вы видели выше). В папке target/release/ появится kopilka.

Шаг 4. Сценарий

Запустите готовую программу из папки проекта. Можно набрать команды руками, а в macOS и Linux — подать их разом через конвейер (в Windows printf нет, набирайте вручную и запускайте target\release\kopilka.exe):

Терминал
printf 'добавить 350 еда обед в столовой\nдобавить 1200 транспорт проездной\nдобавить 99 отдых кино\nсписок\nотчёт\nдобавить abc еда\nпицца\nвыход\n' | ./target/release/kopilka
Вывод
Копилка 0.7
Наберите помощь, чтобы увидеть команды.
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
Записано: 1200 руб., транспорт, «проездной»
Потрачено: 1550 руб. из 30000 руб.
Статус: в норме
Записано: 99 руб., отдых, «кино»
Потрачено: 1649 руб. из 30000 руб.
Статус: в норме
1. 350 руб., еда, «обед в столовой»
2. 1200 руб., транспорт, «проездной»
3. 99 руб., отдых, «кино»
транспорт:   1200 руб.   72%
еда:          350 руб.   21%
отдых:         99 руб.    6%
Всего: 1649 руб. из 30000 руб.
Статус: в норме
Ошибка: «abc» — не сумма
Ошибка: Не знаю команду «пицца»
Итого потрачено: 1649 руб. из 30000 руб.

Разбор по строкам

  • Первые три команды дописывают траты; после каждой видны «Потрачено» и «Статус».
  • список печатает траты с номерами, отчёт — суммы по категориям по убыванию, с долей в процентах.
  • добавить abc еда и пицца дают понятные Ошибка: ..., программа не падает.
  • выход завершает цикл, и печатается итог.

Шаг 5. Данные сохранились

В папке проекта появился файл kopilka.txt:

Текст · kopilka.txt
350;еда;обед в столовой
1200;транспорт;проездной
99;отдых;кино

Запустите программу ещё раз и наберите список и выход: она сама скажет, сколько трат загружено.

Вывод
Копилка 0.7
Наберите помощь, чтобы увидеть команды.
Загружено трат: 3
1. 350 руб., еда, «обед в столовой»
2. 1200 руб., транспорт, «проездной»
3. 99 руб., отдых, «кино»
Итого потрачено: 1649 руб. из 30000 руб.

Проверьте себя

  • cargo fmt --check и cargo clippy отработали без замечаний, а cargo test показал 10 passed.
  • Файл target/release/kopilka (или kopilka.exe) существует, и программа запускается из терминала.
  • После выхода в папке, откуда запускали, лежит kopilka.txt с тремя строками.
  • Второй запуск пишет Загружено трат: 3.
  • Вы можете объяснить своими словами, что делает каждый из пяти файлов.

Частые ошибки

1. Релиз запущен из другой папки

Что видно: вы вернулись в домашнюю папку, запустили ~/kopilka/target/release/kopilka и увидели Пока нет трат, хотя вчера записывали. Почему: DATA_FILE — относительное имя, и программа берёт kopilka.txt из текущей папки, а не из папки, где лежит сам файл программы. В другой папке она тихо создаст новый файл. Как исправить: запускайте из той же папки, а чтобы запись попала в нужный файл, перейдите в неё командой cd перед запуском.

Вывод
Копилка 0.7
Наберите помощь, чтобы увидеть команды.
Пока нет трат
Итого потрачено: 0 руб. из 30000 руб.

2. Запущена не та сборка

Что видно: вы поправили код, собрали cargo build --release, но запускаете файл из target/debug/ и не видите изменений (или наоборот). Почему: у профилей разные папки, и каждая хранит свою последнюю сборку. Как исправить: после правки пересоберите нужный профиль и запускайте файл из его папки, либо используйте cargo run / cargo run --release.

3. Предупреждения Clippy приняты за ошибки

Что видно: красные строки с warning, и кажется, что проект сломан. Почему: предупреждение — совет, а не запрет: сборка и запуск проходят. Как исправить: прочитайте строку help и откройте ссылку на правило. Применяйте совет, если он вам понятен.

Что важно запомнить

  • cargo build --release собирает оптимизированную версию в target/release/; обычная сборка (debug) лежит в target/debug/ и собирается быстрее.
  • Релиз нужно запускать из той папки, где должен лежать kopilka.txt: имя файла в программе относительное.
  • В release переполнение чисел не вызывает панику, поэтому границы данных проверяйте сами.
  • cargo fmt приводит код к единому виду (--check только показывает расхождения), cargo clippy даёт советы; предупреждения — не ошибки.
  • Проект из пяти файлов держится на разделении ролей: ввод и цикл — main.rs, данные — expense.rs, команды и ошибки — command.rs, итоги — report.rs, файл — storage.rs.
  • Привычка перед сдачей работы: cargo fmt, cargo clippy, cargo test, cargo build --release.

Вот и весь путь: от первой строки println! до собственной программы с тестами и сохранением данных. Спасибо, что дошли до конца, и удачи с Rust!