CLI на Rust: мини-проект с аргументами командной строки

В этом уроке мы сделаем маленькую CLI-утилиту на Rust. Она будет принимать аргументы командной строки, проверять команду, печатать результат или понятную ошибку. Это хороший мини-проект после ownership, Result, коллекций и struct

Мы не будем подключать внешние crates. Для первого CLI достаточно стандартной библиотеки: std::env::args, Result, match и аккуратного разбора аргументов

Что получится в конце

Создайте проект:

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

Оцените статью
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x