Введение#
Сегодня мы создадим консольную утилиту на Rust. По задумке она должна помочь мастеру находить устройства в конфигурации умного дома по короткому запросу.
Например можно ввести: “лампочка” или “розетка”.
Это практический пример для отладки и настройки систем умного дома.
Параллельно с этим мы разберём основы: работу с аргументами, чтение файла, парсинг, фильтрацию и безопасную обработку данных.
Шаг 0: Создание проекта#
Перед началом работы нам нужно создать структуру проекта. Rust использует инструмент Cargo (о нем мы уже говорили в начальной статье, но можно и напомнить) - это менеджер пакетов, сборщик и система управления зависимостями. Практически все проекты на Rust начинаются с cargo new.
Откроем терминал и выполним команды:
cargo new smart_helper
cd smart_helper
smart_helper с шаблоном проекта: src/main.rs, Cargo.toml, и .gitignore.Что внутри#
После команды cargo new структура проекта будет следующей:
smart_helper/
├── Cargo.toml
└── src
    └── main.rs
Cargo.toml#
Файл конфигурации, где указываются:
- имя и версия пакета
- зависимости (dependencies)
- тип проекта (бинарный или библиотека в нашем случае бинарный)
На данный момент содержимое будет минимальным:
[package]
name = "smart_helper"
version = "0.1.0"
edition = "2024"
[dependencies]
main.rs#
Файл src/main.rs — точка входа:
fn main() {
    println!("Hello, world!");
}
Мы его позже заменим на логику CLI-приложения.
Запуск проекта#
Проверим, что проект компилируется:
cargo run
Вывод должен быть:
Hello, world!
Совет: Никогда не начинай Rust-проект без cargo new. Это избавит от множества ручных ошибок и создаст правильную структуру сразу.
Готово! У нас есть чистая заготовка для приложения. Теперь можно переходить к следующему шагу — работе с аргументами командной строки.
Шаг 1: Получение аргументов командной строки#
Язык Rust предоставляет модуль std::env. Он позволяет взаимодействовать с окружением операционной системы. Кроме того он позволяет получить доступ к аргументам командной строки - именно этой возможностью мы и воспользуемся.
Начнём с кода:#
Что потребуется подключить?#
Нам понадобится стандартная библиотека Rust. А именно подключенные модули:
use std::{
    env,
    error::Error,
    fs::File,
    io::{BufRead, BufReader},
};
Собираем аргументы#
fn main() -> Result<(), Box<dyn Error>> {
    let args: Vec<String> = env::args().collect();
    if args.len() != 3 {
        println!("Использование: smart_helper <config_file> <поисковая_фраза>");
        return Ok(());
    }
    Ok(())
}
- env::args()1 - функция из модуля- std::env. Она возвращает итератор по аргументам, переданным в командной строке.
- Аргументы возвращаются как std::env::Args- это “ленивый итератор” который мы собираем в вектор с помощью.collect()(про итераторы мы поговорим в другой раз, сейчас надо представлять их как функцию которая возвращает следующий доступный элемент при вызове, а ленивый потому что функция не вызвается сразу, а только когда после неё будет вызван специальный метод, как в нашем случае collect)
- .collect()2 преобразует итератор в коллекцию- Vec<String>- удобный для нас тип. Он позволяет обращаться к аргументам по индексу.
- Первый элемент args[0]- это путь к исполняемому файлу. Поэтому пользовательские значения начинаются с args[1].
Почему Vec<String>?#
- env::args() возвращает элементы типа String, а не&str, потому что данные копируются из системного ввода в память программы. Это значит:- нельзя использовать &strтак как у нас нет ссылки на оригинальные аргументы;
- Stringэто безопасный способ владения строками в Rust;
- Vec<String>даёт произвольный доступ, например args[1], args[2], что невозможно с чистым итератором.
 
- нельзя использовать 
Зачем мы проверяем длину через len?#
if args.len() != 3
Потому что мы ожидаем:
- args[0]- путь к самому бинарнику;
- args[1]— путь к конфигурационному файлу;
- args[2]— строка поиска. Если аргументов меньше или больше - приложение выведет подсказку и завершится корректно.
args.len() программа может аварийно завершиться при попытке обратиться к несуществующему элементу в Vec.Альтернативный вариант: итератор без collect#
Можно было бы сразу использовать let mut args = env::args(); но это неудобно:
let config_path = args.nth(1).unwrap();
let query = args.next().unwrap();
Этот стиль менее удобен для новичков и не позволяет легко вывести ошибку, если аргументов недостаточно. Поэтому в этом проекте мы используем явный Vec<String>.
Шаг 2: Открытие и буферизация файла#
let file = File::open(&args[1])?;
let reader = BufReader::new(file);
- BufReaderдаёт построчный доступ без загрузки всего файла в память.- (Про BufReader мы также еще поговорим отдельно)
 
Шаг 3: Разбор одного блока конфигурации#
Мы подошли к важной части: нужно распарсить (разобрать на составляющие) один блок из текстового файла, который содержит описание устройства. Конфигурация у нас будет выглядеть вот так:
device_id: light_hue_rgb_03
name: Philips Hue RGB лампочка
location: Гостиная (люстра)
serial: PH-HUE-03
features: [диммируемая, цветная]
Блоки разделяются через строку "---"
Наша задача - превратить этот блок в структуру Device с которой уже можно работать как с Rust-объектом.
Структура устройства#
Сначала определим структуру, в которую будем собирать данные:
#[derive(Default)]  
    3
struct Device {
    device_id: String,
    name: String,
    location: String,
    serial: String,
    features: Vec<String>,
}
#[derive(Default)] 3
позволяет создать пустой Device по умолчанию вызовом Device::default() это удобно для постепенного заполнения.Парсинг блока#
fn parse_device(block: &str) -> Option<Device> {
    let mut device = Device::default();
    for line in block.lines() { 
    4
        let parts: Vec<&str> = line.splitn(2, ':').collect(); 
    5
 
        if parts.len() != 2 { continue; }
        let key = parts[0].trim(); 
    6
        let value = parts[1].trim();
        match key { 
    7
            "device_id" => device.device_id = value.to_string(),
            "name" => device.name = value.to_string(),
            "location" => device.location = value.to_string(),
            "serial" => device.serial = value.to_string(),
            "features" => {
                let cleaned = value.trim_matches(&['[', ']'][..]);
                device.features = cleaned
                    .split(',')
                    .map(|s| s.trim().to_string())
                    .collect();
            }
            _ => {}
        }
    }
    Some(device) 
    8
}
Много кода получилось, давайте немного остановимся и разберём строки подробнее:
block.lines()#
4 Функция lines() создаёт итератор по строкам.
Например из этого блока конфига:
device_id: light01
name: Умная лампочка
получим:
["device_id: light01", "name: Умная лампочка"]
splitn(2, ':')#
5
Используем splitn(2, ‘:’) — это важно потому что эта функция разбивает строку только один раз даже если значение содержит :
Пример:
location: Гостиная: зона 2
Результат:
["location", "Гостиная: зона 2"]
trim()#
6
trim() убирает пробелы и символы перевода строки с обеих сторон:
let key = parts[0].trim();
let value = parts[1].trim();
Это избавляет от проблем при работе с файлами, особенно если строки написаны вручную.
match key#
7 Мы проходим по всем строкам и в зависимости от поля присваиваем значение в device.
Разберём что происходит:
"device_id" => device.device_id = value.to_string()
- .to_string()копирует- &strв- String— потому что структура содержит- Stringа не ссылку.
Разбор features#
Поле features хранится в виде строки:
[диммируемая, цветная]
Чтобы превратить её в Vec<String>:
let cleaned = value.trim_matches(&['[', ']'][..]);
device.features = cleaned
    .split(',')
    .map(|s| s.trim().to_string())
    .collect();
Пояснение#
- trim_matches(&['[', ']'])убирает квадратные скобки.
- split(',')разбивает строку по запятой.
- map(|s| s.trim().to_string())— убирает пробелы, превращает в- String.
- collect()собирает вектор.
Возврат результата#
В конце 8
Some(device)
Мы не обрабатываем ошибки парсинга явно — просто пропускаем некорректные строки.
Шаг 4: Основной цикл чтения и фильтрации#
На данный момент мы подготовили структуру Device, научились её заполнять на основе одного текстового блока. Теперь надо прочитать весь файл, разбить его на блоки и выбрать те, что соответствуют поисковой строке.
Вот фрагмент кода, реализующий эту логику:
let mut buffer = String::new();
let mut matches = Vec::new();
let query = args[2].to_lowercase();
for line in reader.lines() {
    let line = line?;
    if line.trim() == "---" {
        if let Some(device) = parse_device(&buffer) {
            if device.name.to_lowercase().contains(&query) {
                matches.push(device);
            }
        }
        buffer.clear();
    } else {
        buffer.push_str(&line);
        buffer.push('\n');
    }
}
Что же здесь происходит?#
- let mut buffer = String::new()- Создаём временную строку, в которую будем записывать строки блока конфигурации до тех пор пока не встретим строку-разделитель ---.
 
- Создаём временную строку, в которую будем записывать строки блока конфигурации до тех пор пока не встретим строку-разделитель 
Каждый такой буфер содержит один логический блок (устройство) в текстовом представлении.
- let mut matches = Vec::new()- Вектор, куда мы будем складывать устройства, прошедшие фильтр. Это наш итоговый результат.
 
- let query = args[2].to_lowercase()- Переводим строку поиска в нижний регистр — это позволяет искать независимо от регистра:
lamp == Лампочка == ЛАМПОЧКА
 
- Переводим строку поиска в нижний регистр — это позволяет искать независимо от регистра:
Проход по файлу#
for line in reader.lines() {
    let line = line?;
Мы читаем файл построчно — BufReader здесь работает максимально эффективно. Каждая строка —> Result<String>, поэтому мы используем ? для автоматического выхода в случае ошибки.
Граница блока:
if line.trim() == "---"
Когда встречаем строку ---, это значит, что закончился блок. Всё, что накоплено в buffer —> это одно устройство.
Парсинг + фильтрация#
if let Some(device) = parse_device(&buffer) {
    if device.name.to_lowercase().contains(&query) {
        matches.push(device);
    }
}
Разбор#
- parse_device()пытается разобрать buffer в структуру Device
- if let Some(...)— обрабатывает случай если результат удачный (- Some)
- device.name.to_lowercase().contains(&query)— ищет подстроку без учёта регистра
- matches.push(device)— сохраняем устройство, которое подошло под критерии
Почему buffer.clear()?#
После обработки одного блока мы обнуляем буфер чтобы начать записывать следующий.
Что же делать с последним блоком?#
Если в конце файла нет завершающей строки ---, последний блок так и останется в buffer. Поэтому отдельно обрабатываем его после цикла:
if !buffer.trim().is_empty() {
    if let Some(device) = parse_device(&buffer) {
        if device.name.to_lowercase().contains(&query) {
            matches.push(device);
        }
    }
}
Это защита от потери последнего элемента - частая ошибка при обработке по разделителям.
Шаг 5: Вывод результата#
Наконец мы собрали все подходящие устройства в Vec<Device> под названием matches. Теперь нужно вывести их в консоль.
Rust не имеет встроенного форматирования таблиц или цветного вывода — всё делается вручную через println!.
Вот полный блок кода:
if matches.is_empty() {
    println!("🚫 Ничего не найдено по запросу '{}'", query);
} else {
    println!("🔍 Найдено {} совпадений:", matches.len());
    for device in matches {
        println!("Название: {}", device.name);
        println!("ID: {}", device.device_id);
        println!("Местоположение: {}", device.location);
        println!("Серийный номер: {}", device.serial);
        println!("Возможности: {}", device.features.join(", "));
    }
}
Проверка на пустой результат#
if matches.is_empty()
Мы не хотим запускать цикл, если ничего не найдено. Метод .is_empty() проверяет, что длина вектора равна нулю.
Вывод количества совпадений#
println!("🔍 Найдено {} совпадений:", matches.len());
Форматирование через макрос println! с подстановкой переменной. Здесьmatches.len() — возвращает usize, который можно спокойно вставить в форматируемую строку.
Пример конфигурационного файла#
device_id: light_hue_rgb_01
name: Умная лампочка Philips Hue RGB
location: Гостиная
serial: PH-HUE-01
features: [диммируемая, цветная]
---
device_id: plug_lenovo_16a
name: Розетка умная Lenovo
location: Кухня
serial: LN-PLUG-44891
features: [мониторинг_питания]
Пример работы#
Если в файле конфига есть подходящие устройства и мы запустили:
cargo run devices.txt ламп
Вывод может быть таким:
🔍 Найдено 1 совпадений:
Название: Умная лампочка Philips Hue RGB
ID: light_hue_rgb_01
Местоположение: Гостиная (люстра)
Серийный номер: PH-HUE-01
Возможности: диммируемая, цветная
Готово! 🚀 Мы не просто написали утилиту, а разобрали все ключевые части Rust-приложения: от создания проекта и ввода данных до построчного чтения, обработки и красивого вывода.
Заключение#
Мы создали полноценную CLI-утилиту на Rust для поиска устройств по описанию из конфигурационного файла. Приложение построено на стандартной библиотеке, без сторонних зависимостей. При этом оно уже решает реальную задачу.
Что мы изучили в процессе написания этого проекта:
- Работа с аргументами командной строки
- Построчное чтение файла
- Поиск по подстроке
- Парсинг структур вручную
👇🏻 Полный код проекта вы можете найти в репозитории
CLI на Rust для поиска устройств в текстовых конфигурациях. Учебный проект