В этом уроке мы сделаем маленькую CLI-утилиту на Rust. Она будет принимать аргументы командной строки, проверять команду, печатать результат или понятную ошибку. Это хороший мини-проект после ownership, Result, коллекций и struct
Мы не будем подключать внешние crates. Для первого CLI достаточно стандартной библиотеки: std::env::args, Result, match и аккуратного разбора аргументов
- Что получится в конце
- Первый код CLI
- std::env::args: откуда берутся аргументы
- Почему run возвращает Result
- Добавляем команду list
- Мини-практика: help-команда
- Как тестировать run без запуска терминала
- Код завершения для CLI
- Разбор аргументов через iterator
- Help-текст как отдельная функция
- Когда проект вырастает из одного main.rs
- Частые ошибки и порядок проверки
- Мини-практика: сохранить задачу в файл
- Структура результата для будущего роста
- Что может быть еще интересно по этой теме
- Что почитать дальше по Rust
Что получится в конце
Создайте проект:
cargo new rust_cli_tasks
cd rust_cli_tasks
Команда запуска будет такой:
cargo run -- add "write lesson"
Ожидаемый вывод:
Added task: write lesson
Если команда неверная:
cargo run -- done
получим сообщение:
Usage: add <task>
Первый код CLI
Вставьте в src/main.rs:
use std::env;
fn run(args: &[String]) -> Result<String, String> {
if args.len() != 3 {
return Err(String::from("Usage: add <task>"));
}
let command = &args[1];
let task = &args[2];
match command.as_str() {
"add" => Ok(format!("Added task: {task}")),
_ => Err(String::from("Usage: add <task>")),
}
}
fn main() {
let args: Vec<String> = env::args().collect();
match run(&args) {
Ok(message) => println!("{message}"),
Err(error) => eprintln!("{error}"),
}
}
Запуск:
cargo run -- add "write lesson"
Две черты -- важны: все, что после них, передается вашей программе, а не самому Cargo
std::env::args: откуда берутся аргументы
env::args() возвращает iterator по аргументам запуска. Первый элемент обычно путь к исполняемому файлу, поэтому команда:
cargo run -- add "write lesson"
дает примерно такой список:
target/debug/rust_cli_tasks
add
write lesson
Поэтому в коде мы проверяем args.len() != 3. Для нашей простой утилиты нужно ровно три элемента: путь, команда, текст задачи
В реальных CLI разбор аргументов часто делают через crates вроде clap, но первый урок полезно пройти вручную. Тогда понятно, что именно библиотека потом автоматизирует
Почему run возвращает Result
Функция:
fn run(args: &[String]) -> Result<String, String>
говорит: либо вернется сообщение для вывода, либо текст ошибки. Это лучше, чем печатать все внутри run, потому что логику проще тестировать
Ok(format!(...)) означает успешный сценарий. Err(String::from(...)) означает ошибку пользовательского ввода
В main мы решаем, куда вывести результат:
match run(&args) {
Ok(message) => println!("{message}"),
Err(error) => eprintln!("{error}"),
}
Ошибку печатаем через eprintln!, то есть в stderr. Это нормальная привычка для CLI: успешный вывод и сообщения об ошибках идут в разные потоки
Добавляем команду list
Расширим пример:
fn run(args: &[String]) -> Result<String, String> {
if args.len() < 2 {
return Err(String::from("Usage: add <task> | list"));
}
match args[1].as_str() {
"add" => {
if args.len() != 3 {
return Err(String::from("Usage: add <task>"));
}
Ok(format!("Added task: {}", args[2]))
}
"list" => Ok(String::from("No saved tasks yet")),
_ => Err(String::from("Unknown command")),
}
}
Проверка:
cargo run -- list
cargo run -- add "publish Rust article"
cargo run -- remove 1
Пока задачи не сохраняются в файл. Это нормально: урок посвящен аргументам и структуре CLI, а хранение данных лучше добавлять отдельным шагом
Мини-практика: help-команда
Добавьте отдельную команду:
"help" => Ok(String::from("Commands: add <task>, list, help")),
Теперь:
cargo run -- help
должно печатать подсказку. Это маленькое изменение полезно тем, что заставляет аккуратно расширить match и не ломать существующие команды
Как тестировать run без запуска терминала
Функция run принимает срез строк, поэтому ее можно проверить прямо в коде:
#[cfg(test)]
mod tests {
use super::run;
#[test]
fn adds_task() {
let args = vec![
String::from("app"),
String::from("add"),
String::from("write"),
];
assert_eq!(run(&args).unwrap(), "Added task: write");
}
}
Запуск:
cargo test
Даже один тест показывает хорошую архитектурную привычку: main занимается вводом-выводом, а run содержит проверяемую логику
Код завершения для CLI
Хорошая CLI-программа не только печатает ошибку, но и завершает процесс с ненулевым кодом. Для первого варианта можно сделать так:
fn main() {
let args: Vec<String> = std::env::args().collect();
if let Err(error) = run(&args) {
eprintln!("{error}");
std::process::exit(1);
}
}
Нулевой код обычно означает успех. Ненулевой код говорит shell, скрипту или CI, что команда завершилась ошибкой. Это важно, если вашу утилиту будут использовать не руками, а внутри автоматизации
Для успешного результата можно печатать сообщение и завершаться обычным образом. Rust сам вернет код 0, если main закончился без panic и без явного process::exit
Разбор аргументов через iterator
Вектор аргументов понятен для первого урока, но можно разбирать iterator:
let mut args = std::env::args();
let _program = args.next();
let command = args.next();
let value = args.next();
Каждый next() возвращает Option<String>, потому что следующего аргумента может не быть. Это снова связывает CLI с темой Option
Такой стиль удобен, когда вы хотите последовательно читать аргументы и сразу обрабатывать отсутствие значения. Но для тестируемой функции run(&[String]) вектор часто проще
Help-текст как отдельная функция
Чтобы не копировать подсказку в нескольких ветках, вынесите ее:
fn usage() -> String {
String::from("Usage: add <task> | list | help")
}
Теперь в ошибках можно писать:
return Err(usage());
Это маленькая деталь, но она делает код взрослее. Если формат команд изменится, вы поправите подсказку в одном месте, а не будете искать одинаковые строки по файлу
Когда проект вырастает из одного main.rs
Пока пример маленький, один main.rs нормален. Когда команд становится больше, логику лучше вынести в src/lib.rs, а в main.rs оставить только чтение аргументов и вывод
Так появляются нормальные тесты без запуска процесса. CLI остается тонкой оболочкой, а ядро программы можно проверять обычным cargo test. Это один из самых практичных приемов для Rust-утилит
Частые ошибки и порядок проверки
Забыли две черты в cargo run Пишите cargo run -- add task. Без -- Cargo может принять аргументы за свои параметры
Считаете args с нуля неправильно args[0] — это обычно путь к программе. Пользовательская команда начинается с args[1]
Печатаете ошибки через println! Для CLI лучше использовать eprintln!, чтобы ошибка ушла в stderr
Смешали разбор аргументов и всю логику в main Вынесите логику в run. Так программу проще расширять и тестировать
Мини-практика: сохранить задачу в файл
Чтобы CLI стал полезнее, можно добавить запись в файл:
use std::fs::OpenOptions;
use std::io::Write;
fn save_task(task: &str) -> Result<(), std::io::Error> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open("tasks.txt")?;
writeln!(file, "{task}")?;
Ok(())
}
Теперь команда add может не только печатать сообщение, но и сохранять задачу между запусками. Здесь снова появляется Result: файл может не открыться, запись может не пройти, и это нормальные ошибки, а не повод делать unwrap
Для первого CLI можно оставить сохранение как следующий шаг. Но полезно видеть, куда растет проект: аргументы командной строки, функция обработки, файл, тесты, потом уже внешняя библиотека для удобного парсинга
Структура результата для будущего роста
Когда команд станет больше, строкового ответа может быть мало. Можно создать enum:
enum CommandOutput {
Message(String),
Help(String),
}
Это не обязательно для первого урока, но показывает Rust-подход: вместо набора разрозненных строк и флагов можно выразить результат типом. Тогда компилятор поможет не забыть новый вариант вывода
Что может быть еще интересно по этой теме
Когда подключать clap? Когда появляются флаги, подкоманды, help, версии и сложная валидация. Но сначала полезно понять env::args
Можно ли читать файлы в этом CLI? Да. Следующий шаг — добавить путь к файлу и читать его через std::fs, как в проекте grep из Rust Book
Почему Result возвращает String-ошибку? Для первого CLI это достаточно. В более серьезном коде лучше использовать отдельный тип ошибки или библиотеку для error handling
Что дальше после CLI? Сравнить Rust и C++ по системным сценариям: безопасность памяти, tooling, порог входа и зрелость экосистемы
Что почитать дальше по Rust
- Типы, match, Option и Result в Rust
- Vec, String и HashMap в Rust
- Traits и generics в Rust без академического тумана
- Rust и C++: что выбрать для системного кода



