Перейти к основному содержимому
  1. Rust/

Поиск данных в файле - Cli хелпер для мастера с помощью Rust

1812 слов·9 минут· loading · loading · ·
Rust Dev
Оглавление
about-rust - Эта статья часть цикла.
Часть 19: Эта статья

Введение
#

Сегодня мы создадим консольную утилиту на 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

Потому что мы ожидаем:

  1. args[0] - путь к самому бинарнику;
  2. args[1] — путь к конфигурационному файлу;
  3. 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"]

Если бы мы использовали split(’:’), результат был бы некорректен — ‘Гостиная’ и ’ зона 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() проверяет, что длина вектора равна нулю.

Это читаемый и эффективный способ, эквивалент matches.len() == 0, но он более привычный и используемый.

Вывод количества совпадений
#

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 для поиска устройств по описанию из конфигурационного файла. Приложение построено на стандартной библиотеке, без сторонних зависимостей. При этом оно уже решает реальную задачу.

Что мы изучили в процессе написания этого проекта:

  • Работа с аргументами командной строки
  • Построчное чтение файла
  • Поиск по подстроке
  • Парсинг структур вручную
Мы написали простой, но качественный инструмент. А ещё — разобрали важные аспекты Rust, от итераторов до ошибок, от строк до структур. Это именно тот путь, по которому изучается язык.

👇🏻 Полный код проекта вы можете найти в репозитории

eaglebk/smart_helper

CLI на Rust для поиска устройств в текстовых конфигурациях. Учебный проект

Rust
0
0
about-rust - Эта статья часть цикла.
Часть 19: Эта статья

Связанные статьи

Ввод данных от пользователя в Rust: как использовать stdin
344 слов·2 минут· loading · loading
Rust Dev
Arc в Rust: потокобезопасное разделённое владение
574 слов·3 минут· loading · loading
Rust Dev
Rc в Rust: Руководство по разделённому владению и RefCell
771 слово·4 минут· loading · loading
Rust Dev