← Назад к списку тем

24. CLI-инструменты и TUI

clap (derive API), конфигурация через config/figment, ratatui для TUI, цветной вывод, автодополнение.

clap — derive API для аргументов командной строки

clap — стандарт де-факто для CLI в Rust. Derive API описывает аргументы через структуру с атрибутами, а сам парсер, help-текст и валидация генерируются макросом на этапе компиляции.

use clap::{Parser, ValueEnum};

#[derive(Parser, Debug)]
#[command(name = "myapp", version, about = "Пример CLI-приложения")]
struct Cli {
    /// Путь к конфигурационному файлу
    #[arg(short, long, default_value = "config.toml")]
    config: String,

    /// Уровень логирования
    #[arg(short, long, value_enum, default_value_t = LogLevel::Info)]
    log_level: LogLevel,

    /// Подробный вывод (можно повторять: -vvv)
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Clone, Copy, ValueEnum, Debug)]
enum LogLevel { Debug, Info, Warn, Error }

#[derive(clap::Subcommand, Debug)]
enum Commands {
    /// Создать пользователя
    Create {
        name: String,
        #[arg(short, long)]
        email: Option<String>,
    },
    /// Список пользователей
    List {
        #[arg(short, long, default_value_t = 20)]
        limit: u32,
    },
}

fn main() {
    let cli = Cli::parse(); // при ошибке — usage в stderr и exit(2)

    match cli.command {
        Commands::Create { name, email } => {
            println!("creating user {name} ({:?})", email);
        }
        Commands::List { limit } => {
            println!("listing up to {limit} users");
        }
    }
}
Критерийclap derive APIclap builder API
Стильструктура + атрибуты, декларативноцепочка вызовов Command::new()...
Читаемостьвыше, схема аргументов видна сразу как типниже при большом числе аргументов
Динамикаограничена — структура фиксирована на этапе компиляцииудобнее для CLI, генерируемых во время выполнения
Когда выбратьбольшинство типовых CLI-инструментовплагинные системы, генерация аргументов из внешних источников

Конфигурация — config и figment

Продакшен-CLI редко довольствуется только аргументами: нужен слой из файла конфигурации, переменных окружения и флагов с чёткой приоритизацией. figment — гибкая библиотека слияния источников конфигурации с явным порядком приоритетов.

use figment::{Figment, providers::{Format, Toml, Env, Serialized}};
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Config {
    server_port: u16,
    log_level: String,
    #[serde(default)]
    dry_run: bool,
}

fn load_config(cli_overrides: &Cli) -> Result<Config, figment::Error> {
    Figment::new()
        .merge(Serialized::defaults(Config { server_port: 8080, log_level: "info".into(), dry_run: false }))
        .merge(Toml::file(&cli_overrides.config))  // config.toml перекрывает defaults
        .merge(Env::prefixed("MYAPP_"))         // env перекрывает файл
        .extract()                              // флаги CLI — высший приоритет, применяются отдельно
}
🔑 Ключевое: Порядок приоритетов конфигурации — от низшего к высшему: значения по умолчанию → файл конфигурации → переменные окружения → флаги CLI. Явное указание всегда должно побеждать неявное.

ratatui — построение TUI

ratatui (продолжение tui-rs) — библиотека немедленного режима отрисовки терминального интерфейса. Приложение хранит состояние, а UI перерисовывается на каждый кадр по этому состоянию — похоже на React/Elm, но без диффинга виртуального DOM.

use ratatui::{
    DefaultTerminal, Frame,
    widgets::{Block, Borders, List, ListItem, ListState},
    crossterm::event::{self, Event, KeyCode},
};

struct App {
    items: Vec<String>,
    state: ListState,
    running: bool,
}

impl App {
    fn draw(&self, frame: &mut Frame) {
        let list = List::new(self.items.iter().map(|i| ListItem::new(i.as_str())))
            .block(Block::default().borders(Borders::ALL).title("Tasks"))
            .highlight_symbol("> ");
        frame.render_stateful_widget(list, frame.area(), &mut self.state.clone());
    }

    fn handle_key(&mut self, key: KeyCode) {
        match key {
            KeyCode::Char('q') => self.running = false,
            KeyCode::Down => self.state.select_next(),
            KeyCode::Up => self.state.select_previous(),
            _ => {}
        }
    }

    fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
        self.running = true;
        while self.running {
            terminal.draw(|f| self.draw(f))?;
            if let Event::Key(key) = event::read()? {
                self.handle_key(key.code);
            }
        }
        Ok(())
    }
}

fn main() -> std::io::Result<()> {
    let mut terminal = ratatui::init(); // raw mode + альтернативный экран
    let result = App { items: vec!["Learn Rust".into(), "Ship it".into()], state: ListState::default(), running: true }
        .run(&mut terminal);
    ratatui::restore(); // terminal должен восстанавливаться даже при панике — см. ниже
    result
}
⚠️ Подводный камень: Если приложение паникует, находясь в raw mode / alternate screen, терминал пользователя может остаться в сломанном состоянии. Устанавливайте panic_hook, который вызывает ratatui::restore() перед тем как передать управление стандартному обработчику паники.

Цветной вывод и форматирование

Для не-TUI CLI цветной вывод улучшает читаемость логов и статусов. Библиотеки owo-colors или colored добавляют ANSI-коды прямо через методы на &str. Важно уважать переменную NO_COLOR и проверять, что вывод действительно идёт в терминал (а не перенаправлен в файл).

use owo_colors::OwoColorize;
use std::io::IsTerminal;

fn print_status(ok: bool, msg: &str) {
    let use_color = std::io::stdout().is_terminal() && std::env::var("NO_COLOR").is_err();

    if !use_color {
        println!("[{}] {msg}", if ok { "OK" } else { "FAIL" });
        return;
    }

    if ok {
        println!("{} {msg}", "✓".green().bold());
    } else {
        println!("{} {msg}", "✗".red().bold());
    }
}

Автодополнение для shell

clap_complete генерирует скрипты автодополнения из той же схемы аргументов, которая используется для парсинга — не нужно поддерживать отдельный список команд.

use clap::{CommandFactory, Parser};
use clap_complete::{generate, Shell};

#[derive(Parser)]
struct Cli {
    #[arg(long, value_enum)]
    generate_completion: Option<Shell>,
    // ...остальные поля
}

fn main() {
    let cli = Cli::parse();

    if let Some(shell) = cli.generate_completion {
        let mut cmd = Cli::command();
        let name = cmd.get_name().to_string();
        generate(shell, &mut cmd, name, &mut std::io::stdout());
        return;
    }
    // обычная логика приложения...
}

// $ myapp --generate-completion zsh > _myapp
// $ myapp --generate-completion bash >> ~/.bashrc

Обработка ошибок и коды возврата

Хороший CLI-инструмент отличает пользовательские ошибки (неверный ввод, файл не найден) от программных багов: первые выводятся кратко без backtrace, вторые — полностью, для отладки. Библиотеки anyhow и miette закрывают эти сценарии.

use anyhow::{Context, Result};
use std::process::ExitCode;

fn run() -> Result<()> {
    let config = std::fs::read_to_string("config.toml")
        .context("failed to read config.toml — did you run `myapp init`?")?;
    // ...
    Ok(())
}

fn main() -> ExitCode {
    if let Err(e) = run() {
        eprintln!("error: {e:#}"); // {:#} печатает всю цепочку context()
        return ExitCode::FAILURE; // 1
    }
    ExitCode::SUCCESS
}
✅ Рекомендация: Возвращайте из main ExitCode вместо вызова std::process::exit напрямую — так корректно отрабатывают деструкторы (Drop) для ресурсов, открытых в стеке main, включая flush буферизованного вывода.

Работа с pipeline: stdin/stdout

Unix-философия — CLI должен хорошо работать в конвейере (cmd1 | myapp | cmd2). Проверка, подключён ли stdin к терминалу, определяет, ждать интерактивный ввод или читать поток.

use std::io::{self, Read, IsTerminal, Write, BufWriter};

fn read_input() -> io::Result<String> {
    let stdin = io::stdin();
    if stdin.is_terminal() {
        // интерактивный режим — данных из pipe нет
        return Ok(String::new());
    }
    let mut buf = String::new();
    stdin.lock().read_to_string(&mut buf)?;
    Ok(buf)
}

fn write_output(lines: &[String]) -> io::Result<()> {
    // BufWriter снижает число системных вызовов write() при построчном выводе
    let mut out = BufWriter::new(io::stdout().lock());
    for line in lines {
        writeln!(out, "{line}")?;
    }
    Ok(())
}