Введение#
Сегодня мы создадим консольную утилиту на 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 в структуру Deviceif 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 для поиска устройств в текстовых конфигурациях. Учебный проект