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

> Создаём утилиту командной строки на Rust для поиска устройств в конфигурационном файле по описанию. Пошаговое руководство: чтение аргументов, обработка строк, парсинг блоков, поиск и вывод.

- **URL:** https://eagle2000.space/rust/rust-cli-sh/
- **Date:** 2025-06-17

- **Tags:** rust, dev

---## Введение

Сегодня мы создадим консольную утилиту на Rust. По задумке она должна помочь мастеру находить устройства в конфигурации умного дома по короткому запросу. 

>Например можно ввести: "лампочка" или "розетка". 
>
> Это практический пример для отладки и настройки систем умного дома.

Параллельно с этим мы разберём основы: работу с аргументами, чтение файла, парсинг, фильтрацию и безопасную обработку данных.


## Шаг 0: Создание проекта

Перед началом работы нам нужно создать структуру проекта. Rust использует инструмент Cargo (_о нем мы уже говорили в начальной статье, но можно и напомнить_) - это менеджер пакетов, сборщик и система управления зависимостями. Практически все проекты на Rust начинаются с `cargo new`.

Откроем терминал и выполним команды:
```sh
cargo new smart_helper
cd smart_helper
```

{{< alert "fire" >}}
Команда создаёт директорию `smart_helper` с шаблоном проекта: `src/main.rs`, `Cargo.toml`, и `.gitignore`.
{{< /alert >}}

### Что внутри

После команды `cargo new` структура проекта будет следующей:

```
smart_helper/
├── Cargo.toml
└── src
    └── main.rs
```

#### Cargo.toml
Файл конфигурации, где указываются:
- имя и версия пакета
- зависимости (dependencies)
- тип проекта (бинарный или библиотека _в нашем случае бинарный_)

На данный момент содержимое будет минимальным:

```toml
[package]
name = "smart_helper"
version = "0.1.0"
edition = "2024"

[dependencies]
```

#### main.rs
Файл `src/main.rs` — точка входа:

```rust
fn main() {
    println!("Hello, world!");
}
```

Мы его позже заменим на логику CLI-приложения.


#### Запуск проекта
Проверим, что проект компилируется:

```sh
cargo run
```

Вывод должен быть:

```Hello, world!```

> Совет: Никогда не начинай Rust-проект без cargo new. Это избавит от множества ручных ошибок и создаст правильную структуру сразу.


Готово! У нас есть чистая заготовка для приложения. Теперь можно переходить к следующему шагу — работе с аргументами командной строки.


## Шаг 1: Получение аргументов командной строки

Язык Rust предоставляет модуль std::env. Он позволяет взаимодействовать с окружением операционной системы. Кроме того он позволяет получить доступ к аргументам командной строки - именно этой возможностью мы и воспользуемся.





### Начнём с кода:

#### Что потребуется подключить?

Нам понадобится стандартная библиотека Rust. А именно подключенные модули:

```rust
use std::{
    env,
    error::Error,
    fs::File,
    io::{BufRead, BufReader},
};
```

#### Собираем аргументы

```rust
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()` {{< codelink id="a1" />}} - функция из модуля `std::env`. Она возвращает итератор по аргументам, переданным в командной строке.
- Аргументы возвращаются как `std::env::Args` - это "ленивый итератор" который мы собираем в вектор с помощью `.collect()` (_про итераторы мы поговорим в другой раз, сейчас надо представлять их как функцию которая возвращает следующий доступный элемент при вызове, а ленивый потому что функция не вызвается сразу, а только когда после неё будет вызван специальный метод, как в нашем случае collect_)
- `.collect()` {{< codelink id="a2" />}} преобразует итератор в коллекцию `Vec<String>` - удобный для нас тип. Он позволяет обращаться к аргументам по индексу.
- Первый элемент `args[0]` - это путь к исполняемому файлу. Поэтому пользовательские значения начинаются с args[1].

{{< alert "fire" >}}
#### Почему `Vec<String>?`

* env::args() возвращает элементы типа `String`, а не `&str`, потому что данные копируются из системного ввода в память программы. Это значит:
  - нельзя использовать `&str` так как у нас нет ссылки на оригинальные аргументы;
  - `String` это безопасный способ владения строками в Rust;
  - `Vec<String>` даёт произвольный доступ, например args[1], args[2], что невозможно с чистым итератором.

{{< /alert >}}
</br>


#### Зачем мы проверяем длину через len?

```if args.len() != 3```

Потому что мы ожидаем:
1. `args[0]` - путь к самому бинарнику;
2. `args[1]` — путь к конфигурационному файлу;
3. `args[2]` — строка поиска.
Если аргументов меньше или больше - приложение выведет подсказку и завершится корректно.

{{< alert "fire" >}}
Если не проверять `args.len()` программа может аварийно завершиться при попытке обратиться к несуществующему элементу в `Vec`.
{{< /alert >}}

### Альтернативный вариант: итератор без collect

Можно было бы сразу использовать `let mut args = env::args();` но это неудобно:

```rust
let config_path = args.nth(1).unwrap();
let query = args.next().unwrap();
```

Этот стиль менее удобен для новичков и не позволяет легко вывести ошибку, если аргументов недостаточно. Поэтому в этом проекте мы используем явный `Vec<String>`.

## Шаг 2: Открытие и буферизация файла

```rust
let file = File::open(&args[1])?;
let reader = BufReader::new(file);
```

* `BufReader` даёт построчный доступ без загрузки всего файла в память. 
  * (_Про BufReader мы также еще поговорим отдельно_)


## Шаг 3: Разбор одного блока конфигурации

Мы подошли к важной части: нужно распарсить (разобрать на составляющие) один блок из текстового файла, который содержит описание устройства. Конфигурация у нас будет выглядеть вот так:


```txt
device_id: light_hue_rgb_03
name: Philips Hue RGB лампочка
location: Гостиная (люстра)
serial: PH-HUE-03
features: [диммируемая, цветная]
```

Блоки разделяются через строку `"---"`

Наша задача - превратить этот блок в структуру `Device` с которой уже можно работать как с Rust-объектом.

### Структура устройства

Сначала определим структуру, в которую будем собирать данные:


```rust
#[derive(Default)]  {{< golink id="a3" />}}
struct Device {
    device_id: String,
    name: String,
    location: String,
    serial: String,
    features: Vec<String>,
}
```

{{< alert "fire" >}}
`#[derive(Default)]` {{< codelink id="a3" />}}  позволяет создать пустой `Device` по умолчанию вызовом `Device::default()` это удобно для постепенного заполнения.
{{< /alert >}}



### Парсинг блока

```rust
fn parse_device(block: &str) -> Option<Device> {
    let mut device = Device::default();

    for line in block.lines() { {{< golink id="a4" />}}
        let parts: Vec<&str> = line.splitn(2, ':').collect(); {{< golink id="a5" />}} 
        if parts.len() != 2 { continue; }

        let key = parts[0].trim(); {{< golink id="a6" />}}
        let value = parts[1].trim();

        match key { {{< golink id="a7" />}}
            "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) {{< golink id="a8" />}}
}
```

Много кода получилось, давайте немного остановимся и разберём строки подробнее:

#### `block.lines()`
{{< codelink id="a4" />}} Функция lines() создаёт итератор по строкам. 

Например из этого блока конфига:

```txt
device_id: light01
name: Умная лампочка
```

получим:

```["device_id: light01", "name: Умная лампочка"]```

#### `splitn(2, ':')`
{{< codelink id="a5" />}} Используем splitn(2, ':') — это важно потому что эта функция разбивает строку **только один раз** даже если значение содержит `:`

Пример:

```txt
location: Гостиная: зона 2
```

Результат:
```["location", "Гостиная: зона 2"]```

{{< alert "fire" >}}
Если бы мы использовали split(':'), результат был бы некорректен — 'Гостиная' и ' зона 2' попали бы в разные части.
{{< /alert >}}

#### `trim()`
{{< codelink id="a6" />}} `trim()` убирает пробелы и символы перевода строки с обеих сторон:


```rust
let key = parts[0].trim();
let value = parts[1].trim();
```

Это избавляет от проблем при работе с файлами, особенно если строки написаны вручную.

#### `match key`
{{< codelink id="a7" />}} Мы проходим по всем строкам и в зависимости от поля присваиваем значение в device.

Разберём что происходит:

```rust
"device_id" => device.device_id = value.to_string()
```

- `.to_string()` копирует `&str` в `String` — потому что структура содержит `String` а не ссылку.

#### Разбор `features`
Поле features хранится в виде строки:

```txt
[диммируемая, цветная]
```

Чтобы превратить её в `Vec<String>`:

```rust
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()` собирает вектор.

#### Возврат результата
В конце {{< codelink id="a8" />}} ```Some(device)```

Мы не обрабатываем ошибки парсинга явно — просто пропускаем некорректные строки.

{{< alert "fire" >}}
Можете заметить что мы вручную разбираем структуру без сторонних библиотек. Это полезно для понимания базовой обработки строк и соответствия ключам.
{{< /alert >}}

## Шаг 4: Основной цикл чтения и фильтрации

На данный момент мы подготовили структуру `Device`, научились её заполнять на основе одного текстового блока. Теперь надо прочитать весь файл, разбить его на блоки и выбрать те, что соответствуют поисковой строке.

Вот фрагмент кода, реализующий эту логику:

```rust
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 == Лампочка == ЛАМПОЧКА```

### Проход по файлу

```rust 
for line in reader.lines() {
    let line = line?;
```

Мы читаем файл построчно — `BufReader` здесь работает максимально эффективно. Каждая строка —> `Result<String>`, поэтому мы используем ? для автоматического выхода в случае ошибки.

Граница блока: 

```rust
if line.trim() == "---"
```

Когда встречаем строку `---`, это значит, что закончился блок. Всё, что накоплено в buffer —> это одно устройство.

### Парсинг + фильтрация

```rust
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`. Поэтому отдельно обрабатываем его после цикла:

```rust
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!`.

Вот полный блок кода:

```rust
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(", "));
    }
}
```

### Проверка на пустой результат

```rust
if matches.is_empty()
```

Мы не хотим запускать цикл, если ничего не найдено. Метод `.is_empty()` проверяет, что длина вектора равна нулю.

{{< alert "fire" >}}
Это читаемый и эффективный способ, эквивалент matches.len() == 0, но он более привычный и используемый.
{{< /alert >}}

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

```rust
println!("🔍 Найдено {} совпадений:", matches.len());
```

Форматирование через макрос `println!` с подстановкой переменной. Здесь`matches.len()` — возвращает `usize`, который можно спокойно вставить в форматируемую строку.


## Пример конфигурационного файла

```txt
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 ламп`

Вывод может быть таким:

```yaml
🔍 Найдено 1 совпадений:

Название: Умная лампочка Philips Hue RGB
ID: light_hue_rgb_01
Местоположение: Гостиная (люстра)
Серийный номер: PH-HUE-01
Возможности: диммируемая, цветная
```

Готово! 🚀 Мы не просто написали утилиту, а разобрали **все ключевые части Rust-приложения**: от создания проекта и ввода данных до построчного чтения, обработки и красивого вывода.

## Заключение

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

Что мы изучили в процессе написания этого проекта:
- Работа с аргументами командной строки
- Построчное чтение файла
- Поиск по подстроке
- Парсинг структур вручную

{{< alert "fire" >}}
Мы написали простой, но качественный инструмент. А ещё — разобрали важные аспекты Rust, от итераторов до ошибок, от строк до структур. Это именно тот путь, по которому изучается язык.
{{< /alert >}}

<br>

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

{{< github repo="eaglebk/smart_helper" showThumbnail=true >}}

{{< typeit
      speed=50
>}}
Продолжение следует...
{{< /typeit >}}
