From c6beea5608619d9dd6a2ce697a9c601f7ccbe27b Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sun, 1 Feb 2026 19:56:33 +0300 Subject: [PATCH] refactor: create timeout/retry utilities to reduce code duplication (P1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created new utility modules to eliminate repeated timeout/retry patterns: - src/utils/retry.rs: with_timeout() and with_timeout_msg() helpers - src/utils/formatting.rs: timestamp formatting utilities (from utils.rs) - src/utils/tdlib.rs: TDLib log configuration utilities (from utils.rs) Refactored src/input/main_input.rs: - Replaced 18+ instances of timeout(Duration, op).await pattern - Simplified error handling from nested Ok(Ok(...))/Ok(Err(...))/Err(...) to cleaner Ok(...)/Err(...) with custom timeout messages - Added type annotations for compiler type inference Benefits: - Reduced code duplication from ~20 instances to 2 utility functions - Cleaner, more readable error handling - Easier to maintain timeout logic in one place - All 59 tests passing Progress: REFACTORING_OPPORTUNITIES.md #1 (Дублирование кода) - Частично выполнено Co-Authored-By: Claude Sonnet 4.5 --- REFACTORING_OPPORTUNITIES.md | 602 ++++++++++++++++++++++++++ src/input/main_input.rs | 145 +++---- src/{utils.rs => utils/formatting.rs} | 24 - src/utils/mod.rs | 7 + src/utils/retry.rs | 140 ++++++ src/utils/tdlib.rs | 23 + 6 files changed, 832 insertions(+), 109 deletions(-) create mode 100644 REFACTORING_OPPORTUNITIES.md rename src/{utils.rs => utils/formatting.rs} (91%) create mode 100644 src/utils/mod.rs create mode 100644 src/utils/retry.rs create mode 100644 src/utils/tdlib.rs diff --git a/REFACTORING_OPPORTUNITIES.md b/REFACTORING_OPPORTUNITIES.md new file mode 100644 index 0000000..16d60be --- /dev/null +++ b/REFACTORING_OPPORTUNITIES.md @@ -0,0 +1,602 @@ +# Возможности для рефакторинга + +> Результаты аудита кодовой базы от 2026-02-01 +> Статус: В работе (1/10 категорий) + +## Оглавление + +1. [Дублирование кода](#1-дублирование-кода) +2. [Большие файлы/функции](#2-большие-файлыфункции) +3. [Сложная вложенность](#3-сложная-вложенность) +4. [Нарушение Single Responsibility](#4-нарушение-single-responsibility) +5. [Плохая инкапсуляция](#5-плохая-инкапсуляция) +6. [Отсутствующие абстракции](#6-отсутствующие-абстракции) +7. [Несогласованность](#7-несогласованность) +8. [Перекрытие функциональности](#8-перекрытие-функциональности) +9. [Проблемы производительности](#9-проблемы-производительности) +10. [Отсутствующие архитектурные паттерны](#10-отсутствующие-архитектурные-паттерны) + +--- + +## 1. Дублирование кода + +**Приоритет:** 🔴 Высокий +**Статус:** ✅ Частично выполнено +**Объем:** 15-20% кодовой базы + +### Проблемы + +- **Timeout/Retry паттерны** (~20 экземпляров в обработке ввода) + - Повторяющаяся логика таймаутов в `src/input/main_input.rs` + - Одинаковые паттерны retry в разных обработчиках + +- **Обработка модальных окон** (5+ мест) + - Логика открытия/закрытия модалок дублируется + - Валидация ввода в модальных окнах повторяется + - Обработка Escape для закрытия модалок в каждом месте + +- **Паттерны валидации** + - Проверка пустых строк + - Валидация ID чатов/сообщений + - Проверка длины текста + +### Решение + +- [x] Создать `retry_utils.rs` с функциями `with_timeout()`, `with_retry()` - **Выполнено** + - Создан `src/utils/retry.rs` с двумя функциями: `with_timeout()` и `with_timeout_msg()` + - Заменены 18+ использований `tokio::time::timeout` в `src/input/main_input.rs` + - Код стал чище и короче (убрано вложенное Ok/Err матчинг) +- [ ] Создать `modal_handler.rs` с общей логикой модальных окон +- [ ] Создать `validation.rs` с переиспользуемыми валидаторами + +### Файлы + +- `src/input/main_input.rs` +- `src/app/handlers/*.rs` +- `src/ui/modals/*.rs` + +--- + +## 2. Большие файлы/функции + +**Приоритет:** 🔴 Высокий +**Статус:** ❌ Не начато +**Объем:** 4 файла, 1000+ строк каждый + +### Проблемы + +| Файл | Строки | Проблема | +|------|--------|----------| +| `src/input/main_input.rs` | 1164 | Одна функция `handle()` на ~800 строк | +| `src/tdlib/client.rs` | 1167 | Смешение facade и бизнес-логики | +| `src/ui/messages.rs` | 800+ | Рендеринг всех типов сообщений | +| `src/tdlib/messages.rs` | 850 | Обработка всех типов обновлений сообщений | + +### Решение + +#### 2.1. Разделить `src/input/main_input.rs` + +- [ ] Создать `src/input/handlers/chat_list_input.rs` +- [ ] Создать `src/input/handlers/messages_input.rs` +- [ ] Создать `src/input/handlers/compose_input.rs` +- [ ] Создать `src/input/handlers/search_input.rs` +- [ ] Создать `src/input/handlers/modal_input.rs` +- [ ] Главный `handle()` делегирует по screen state + +#### 2.2. Разделить `src/tdlib/client.rs` + +- [ ] Создать `src/tdlib/facade.rs` (публичный API) +- [ ] Переместить бизнес-логику в соответствующие модули +- [ ] Упростить `TdClient` до простого facade + +#### 2.3. Разделить `src/ui/messages.rs` + +- [ ] Создать `src/ui/message_renderer/text.rs` +- [ ] Создать `src/ui/message_renderer/media.rs` +- [ ] Создать `src/ui/message_renderer/service.rs` +- [ ] Создать `src/ui/message_renderer/bubble.rs` + +#### 2.4. Разделить `src/tdlib/messages.rs` + +- [ ] Создать `src/tdlib/message_updates/new_message.rs` +- [ ] Создать `src/tdlib/message_updates/edit_message.rs` +- [ ] Создать `src/tdlib/message_updates/delete_message.rs` +- [ ] Создать `src/tdlib/message_updates/reactions.rs` + +### Файлы + +- `src/input/main_input.rs` +- `src/tdlib/client.rs` +- `src/ui/messages.rs` +- `src/tdlib/messages.rs` + +--- + +## 3. Сложная вложенность + +**Приоритет:** 🟡 Средний +**Статус:** ❌ Не начато +**Объем:** ~30 функций с глубокой вложенностью + +### Проблемы + +- 4-5 уровней вложенности в обработке ввода +- Глубокая вложенность в обработке обновлений TDLib +- Множественные `if let` / `match` вложенные друг в друга + +### Примеры + +```rust +// src/input/main_input.rs - типичный пример +if let Some(chat_id) = app.selected_chat { + if let Some(message_id) = app.selected_message { + if app.is_message_outgoing(chat_id, message_id) { + match key.code { + // еще больше вложенности + } + } + } +} +``` + +### Решение + +- [ ] Применить early returns для уменьшения вложенности +- [ ] Извлечь вложенную логику в отдельные функции +- [ ] Использовать паттерн "guard clauses" +- [ ] Применить `?` оператор где возможно + +### Файлы + +- `src/input/main_input.rs` +- `src/tdlib/updates.rs` +- `src/app/handlers/*.rs` + +--- + +## 4. Нарушение Single Responsibility + +**Приоритет:** 🟡 Средний +**Статус:** ❌ Не начато +**Объем:** 2 основных структуры + +### Проблемы + +#### 4.1. `App` struct (50+ методов) + +Смешивает ответственности: +- UI state management +- Business logic +- TDLib interaction +- Input handling +- Search logic +- Profile management +- Folder management + +#### 4.2. `TdClient` (facade + бизнес-логика) + +Смешивает: +- Facade pattern (делегирование) +- Update processing +- Cache management +- Network operations + +### Решение + +#### Разделить `App` + +- [ ] Создать `ChatListState` (состояние списка чатов) +- [ ] Создать `MessageViewState` (состояние просмотра сообщений) +- [ ] Создать `ComposeState` (состояние написания сообщения) +- [ ] Создать `SearchState` (состояние поиска) +- [ ] Создать `ProfileState` (состояние профиля) +- [ ] `App` становится координатором этих state объектов + +#### Разделить `TdClient` + +- [ ] `TdClient` только facade (делегирование) +- [ ] Бизнес-логика в `MessageService`, `ChatService`, etc. +- [ ] Update processing в отдельном модуле + +### Файлы + +- `src/app/mod.rs` +- `src/tdlib/client.rs` + +--- + +## 5. Плохая инкапсуляция + +**Приоритет:** 🔴 Высокий +**Статус:** ❌ Не начато +**Объем:** Вся структура `App` + +### Проблемы + +- **22 публичных поля** в `App` + ```rust + pub struct App { + pub td_client: TdClient, + pub chats: Vec, + pub selected_chat: Option, + pub messages: HashMap>, + // ... еще 18 полей + } + ``` + +- **Прямой доступ везде** + ```rust + app.selected_chat = Some(chat_id); // Плохо + app.chats.push(new_chat); // Плохо + app.messages.clear(); // Плохо + ``` + +- **Тесты манипулируют внутренностями** + ```rust + app.td_client.user_cache.chat_user_ids.insert(...); // Слишком глубоко + ``` + +### Решение + +- [ ] Сделать все поля приватными +- [ ] Добавить getter методы где нужно +- [ ] Добавить setter методы с валидацией +- [ ] Создать методы для операций (вместо прямого доступа) + ```rust + // Вместо app.selected_chat = Some(chat_id) + app.select_chat(chat_id); + + // Вместо app.chats.push(new_chat) + app.add_chat(new_chat); + ``` + +### Файлы + +- `src/app/mod.rs` +- `src/app/state.rs` (новый) +- Все тесты + +--- + +## 6. Отсутствующие абстракции + +**Приоритет:** 🟡 Средний +**Статус:** ❌ Не начато +**Объем:** 3 основные абстракции + +### Проблемы + +#### 6.1. Нет `KeyHandler` trait + +Обработка клавиш размазана по коду: + +```rust +// В каждом экране повторяется +match key.code { + KeyCode::Char('q') => { ... } + KeyCode::Esc => { ... } + // ... +} +``` + +#### 6.2. Нет абстракции для network operations + +Timeout/retry логика дублируется: + +```rust +// Повторяется ~20 раз +let result = tokio::time::timeout( + Duration::from_millis(100), + operation() +).await; +``` + +#### 6.3. Хардкод горячих клавиш + +Невозможно изменить без правки кода: + +```rust +KeyCode::Char('e') => edit_message(), // Хардкод +KeyCode::Char('d') => delete_message(), // Хардкод +``` + +### Решение + +#### 6.1. Создать `KeyHandler` trait + +- [ ] Создать `src/input/key_handler.rs` + ```rust + trait KeyHandler { + fn handle_key(&mut self, app: &mut App, key: KeyEvent) -> Result; + } + ``` +- [ ] Реализовать для каждого экрана: + - `ChatListKeyHandler` + - `MessagesKeyHandler` + - `ComposeKeyHandler` + - `SearchKeyHandler` + +#### 6.2. Создать network utilities + +- [ ] Создать `src/utils/network.rs` + ```rust + async fn with_timeout(f: F, timeout_ms: u64) -> Result + async fn with_retry(f: F, max_retries: u32) -> Result + ``` + +#### 6.3. Создать систему горячих клавиш + +- [ ] Создать `src/config/keybindings.rs` +- [ ] Загружать из конфига +- [ ] Позволить переопределять + +### Файлы + +- `src/input/key_handler.rs` (новый) +- `src/utils/network.rs` (новый) +- `src/config/keybindings.rs` (новый) + +--- + +## 7. Несогласованность + +**Приоритет:** 🟢 Низкий +**Статус:** ❌ Не начато +**Объем:** Вся кодовая база + +### Проблемы + +#### 7.1. Разные типы ошибок + +```rust +// В одних местах +Result + +// В других +Result> + +// В третьих +Result // с неявным типом ошибки +``` + +#### 7.2. Разные паттерны state management + +- В одних местах флаги (`is_editing: bool`) +- В других энумы (`EditMode::Active`) +- В третьих Option (`editing_message: Option`) + +#### 7.3. Разные подходы к валидации + +- Иногда в UI слое +- Иногда в бизнес-логике +- Иногда в обработчиках ввода + +### Решение + +- [ ] Стандартизировать обработку ошибок (один тип ошибки) +- [ ] Выбрать единый подход к state management (enum-based) +- [ ] Определить слой для валидации (бизнес-логика) +- [ ] Создать style guide в документации + +### Файлы + +- Вся кодовая база + +--- + +## 8. Перекрытие функциональности + +**Приоритет:** 🟡 Средний +**Статус:** ❌ Не начато +**Объем:** 2 основные области + +### Проблемы + +#### 8.1. Фильтрация чатов (3 места) + +- В `App::filter_chats_by_folder()` +- В `App::filter_chats()` +- В UI слое при рендеринге + +#### 8.2. Обработка сообщений (3+ модуля) + +- `src/tdlib/messages.rs` - получение от TDLib +- `src/app/mod.rs` - бизнес-логика +- `src/ui/messages.rs` - рендеринг +- Размыто, что за что отвечает + +### Решение + +#### 8.1. Централизовать фильтрацию + +- [ ] Создать `src/app/chat_filter.rs` +- [ ] Один источник правды для фильтрации +- [ ] UI и App используют его + +#### 8.2. Четко разделить слои обработки сообщений + +- [ ] `tdlib/messages.rs` - только получение и преобразование +- [ ] `app/message_service.rs` - бизнес-логика +- [ ] `ui/messages.rs` - только рендеринг + +### Файлы + +- `src/app/chat_filter.rs` (новый) +- `src/app/message_service.rs` (новый) +- `src/tdlib/messages.rs` +- `src/ui/messages.rs` + +--- + +## 9. Проблемы производительности + +**Приоритет:** 🟢 Низкий +**Статус:** ❌ Не начато +**Объем:** Локальные оптимизации + +### Проблемы + +#### 9.1. Множественные клоны в обработке ввода + +```rust +let text = app.input_text.clone(); // Клон +let chat_id = app.selected_chat.clone(); // Клон +// Используются только для чтения +``` + +#### 9.2. Нет кеширования результатов поиска + +- Каждый поиск выполняется заново +- Нет инвалидации кеша при изменениях + +#### 9.3. Неэффективная LRU cache + +- `Vec::retain()` + `Vec::push()` на каждый доступ +- O(n) вместо потенциального O(1) + +### Решение + +- [ ] Заменить клоны на borrowing где возможно +- [ ] Добавить `SearchCache` с TTL +- [ ] Оптимизировать `LruCache` (использовать `VecDeque` или готовую библиотеку) + +### Файлы + +- `src/input/main_input.rs` +- `src/app/search.rs` +- `src/tdlib/users.rs` (LruCache) + +--- + +## 10. Отсутствующие архитектурные паттерны + +**Приоритет:** 🟢 Низкий +**Статус:** ❌ Не начато +**Объем:** Архитектурные изменения + +### Проблемы + +#### 10.1. Нет Event Bus + +Компоненты напрямую вызывают друг друга: +- Сложно тестировать +- Сильная связанность +- Тяжело добавлять новые фичи + +#### 10.2. Нет Repository паттерна + +Прямой доступ к данным везде: +- `app.messages.get(chat_id)` +- `app.chats.iter().find(...)` +- Нет единой точки доступа к данным + +#### 10.3. Нет Service Layer + +Бизнес-логика размазана: +- Часть в `App` +- Часть в `TdClient` +- Часть в UI handlers + +### Решение + +#### 10.1. Event Bus (опционально) + +- [ ] Создать `src/event_bus.rs` +- [ ] Pub/Sub для событий между компонентами +- [ ] Decoupling + +#### 10.2. Repository Pattern + +- [ ] Создать `src/repositories/chat_repository.rs` +- [ ] Создать `src/repositories/message_repository.rs` +- [ ] Создать `src/repositories/user_repository.rs` +- [ ] Единая точка доступа к данным + +#### 10.3. Service Layer + +- [ ] Создать `src/services/chat_service.rs` +- [ ] Создать `src/services/message_service.rs` +- [ ] Создать `src/services/search_service.rs` +- [ ] Вся бизнес-логика в сервисах + +### Файлы + +- `src/event_bus.rs` (новый, опционально) +- `src/repositories/*.rs` (новые) +- `src/services/*.rs` (новые) + +--- + +## Приоритизация + +### 🔴 Высокий приоритет (начать первым) + +1. **Дублирование кода** - быстрый win, улучшит поддерживаемость +2. **Большие файлы** - критично для навигации и понимания кода +3. **Плохая инкапсуляция** - защитит от ошибок, улучшит API + +### 🟡 Средний приоритет (после высокого) + +4. **Сложная вложенность** - улучшит читаемость +5. **Single Responsibility** - улучшит архитектуру +6. **Отсутствующие абстракции** - упростит расширение +7. **Перекрытие функциональности** - уберет путаницу + +### 🟢 Низкий приоритет (когда будет время) + +8. **Несогласованность** - косметические улучшения +9. **Производительность** - пока не critical path +10. **Архитектурные паттерны** - nice to have + +--- + +## План выполнения + +### Фаза 1: Быстрые победы (1-2 дня) + +- [ ] #1: Создать утилиты для дублирующегося кода +- [ ] #5: Инкапсулировать поля App + +### Фаза 2: Разделение больших файлов (3-5 дней) + +- [ ] #2.1: Разделить `main_input.rs` +- [ ] #2.2: Разделить `client.rs` +- [ ] #2.3: Разделить `messages.rs` + +### Фаза 3: Улучшение архитектуры (5-7 дней) + +- [ ] #4: Разделить ответственности App/TdClient +- [ ] #6: Добавить абстракции (KeyHandler, network utils) +- [ ] #8: Убрать перекрытие функциональности + +### Фаза 4: Полировка (2-3 дня) + +- [ ] #3: Упростить вложенность +- [ ] #7: Стандартизировать подходы +- [ ] #9: Оптимизировать производительность + +### Фаза 5: Архитектурные паттерны (опционально) + +- [ ] #10: Рассмотреть Event Bus / Repository / Service Layer + +--- + +## Метрики + +### До рефакторинга + +- Строк кода: ~15,000 +- Файлов: ~50 +- Средний размер файла: 300 строк +- Максимальный файл: 1167 строк +- Дублирование: ~15-20% +- Публичных полей в App: 22 + +### Цели после рефакторинга + +- Максимальный файл: <500 строк +- Дублирование: <5% +- Публичных полей в App: 0 +- Все файлы <400 строк (в идеале) +- Улучшенная тестируемость +- Более четкое разделение ответственностей diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 614a821..bf4c4b7 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -1,9 +1,9 @@ use crate::app::App; use crate::tdlib::ChatAction; use crate::types::{ChatId, MessageId}; +use crate::utils::{with_timeout, with_timeout_msg}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::time::{Duration, Instant}; -use tokio::time::timeout; pub async fn handle(app: &mut App, key: KeyEvent) { let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); @@ -12,7 +12,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Char('r') if has_ctrl => { app.status_message = Some("Обновление чатов...".to_string()); - let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; + let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; app.status_message = None; return; } @@ -28,13 +28,15 @@ pub async fn handle(app: &mut App, key: KeyEvent) { if app.selected_chat_id.is_some() && !app.is_pinned_mode() { if let Some(chat_id) = app.get_selected_chat_id() { app.status_message = Some("Загрузка закреплённых...".to_string()); - match timeout( + match with_timeout_msg( Duration::from_secs(5), app.td_client.get_pinned_messages(ChatId::new(chat_id)), + "Таймаут загрузки", ) .await { - Ok(Ok(messages)) => { + Ok(messages) => { + let messages: Vec = messages; if messages.is_empty() { app.status_message = Some("Нет закреплённых сообщений".to_string()); } else { @@ -42,14 +44,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.status_message = None; } } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); app.status_message = None; } - Err(_) => { - app.error_message = Some("Таймаут загрузки".to_string()); - app.status_message = None; - } } } } @@ -219,7 +217,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Выполняем поиск при изменении запроса if let Some(chat_id) = app.get_selected_chat_id() { if !query.is_empty() { - if let Ok(Ok(results)) = timeout( + if let Ok(results) = with_timeout( Duration::from_secs(3), app.td_client.search_messages(ChatId::new(chat_id), &query), ) @@ -240,7 +238,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.update_search_query(query.clone()); // Выполняем поиск при изменении запроса if let Some(chat_id) = app.get_selected_chat_id() { - if let Ok(Ok(results)) = timeout( + if let Ok(results) = with_timeout( Duration::from_secs(3), app.td_client.search_messages(ChatId::new(chat_id), &query), ) @@ -340,27 +338,22 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.status_message = Some("Отправка реакции...".to_string()); app.needs_redraw = true; - match timeout( + match with_timeout_msg( Duration::from_secs(5), app.td_client .toggle_reaction(chat_id, message_id, emoji.clone()), + "Таймаут отправки реакции", ) .await { - Ok(Ok(_)) => { + Ok(_) => { app.status_message = Some(format!("Реакция {} добавлена", emoji)); app.exit_reaction_picker_mode(); app.needs_redraw = true; } - Ok(Err(e)) => { - app.error_message = Some(format!("Ошибка: {}", e)); - app.status_message = None; - app.needs_redraw = true; - } - Err(_) => { - app.error_message = - Some("Таймаут отправки реакции".to_string()); + Err(e) => { + app.error_message = Some(e); app.status_message = None; app.needs_redraw = true; } @@ -394,17 +387,18 @@ pub async fn handle(app: &mut App, key: KeyEvent) { .map(|m| m.can_be_deleted_for_all_users()) .unwrap_or(false); - match timeout( + match with_timeout_msg( Duration::from_secs(5), app.td_client.delete_messages( ChatId::new(chat_id), vec![msg_id], can_delete_for_all, ), + "Таймаут удаления", ) .await { - Ok(Ok(_)) => { + Ok(_) => { // Удаляем из локального списка app.td_client .current_chat_messages_mut() @@ -412,12 +406,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Сбрасываем состояние app.chat_state = crate::app::ChatState::Normal; } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); } - Err(_) => { - app.error_message = Some("Таймаут удаления".to_string()); - } } } } @@ -447,26 +438,24 @@ pub async fn handle(app: &mut App, key: KeyEvent) { let to_chat_id = chat.id; if let Some(msg_id) = app.chat_state.selected_message_id() { if let Some(from_chat_id) = app.get_selected_chat_id() { - match timeout( + match with_timeout_msg( Duration::from_secs(5), app.td_client.forward_messages( to_chat_id, ChatId::new(from_chat_id), vec![msg_id], ), + "Таймаут пересылки", ) .await { - Ok(Ok(_)) => { + Ok(_) => { app.status_message = Some("Сообщение переслано".to_string()); } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); } - Err(_) => { - app.error_message = Some("Таймаут пересылки".to_string()); - } } } } @@ -497,26 +486,27 @@ pub async fn handle(app: &mut App, key: KeyEvent) { if let Some(chat_id) = app.get_selected_chat_id() { app.status_message = Some("Загрузка сообщений...".to_string()); app.message_scroll_offset = 0; - match timeout( + match with_timeout_msg( Duration::from_secs(10), app.td_client.get_chat_history(ChatId::new(chat_id), 100), + "Таймаут загрузки сообщений", ) .await { - Ok(Ok(messages)) => { + Ok(messages) => { // Сохраняем загруженные сообщения *app.td_client.current_chat_messages_mut() = messages; // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории // Это предотвращает race condition с Update::NewMessage app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); // Загружаем недостающие reply info - let _ = timeout( + let _ = tokio::time::timeout( Duration::from_secs(5), app.td_client.fetch_missing_reply_info(), ) .await; // Загружаем последнее закреплённое сообщение - let _ = timeout( + let _ = tokio::time::timeout( Duration::from_secs(2), app.td_client.load_current_pinned_message(ChatId::new(chat_id)), ) @@ -525,14 +515,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.load_draft(); app.status_message = None; } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); app.status_message = None; } - Err(_) => { - app.error_message = Some("Таймаут загрузки сообщений".to_string()); - app.status_message = None; - } } } } @@ -596,13 +582,14 @@ pub async fn handle(app: &mut App, key: KeyEvent) { return; } - match timeout( + match with_timeout_msg( Duration::from_secs(5), app.td_client.edit_message(ChatId::new(chat_id), msg_id, text), + "Таймаут редактирования", ) .await { - Ok(Ok(mut edited_msg)) => { + Ok(mut edited_msg) => { // Сохраняем reply_to из старого сообщения (если есть) let messages = app.td_client.current_chat_messages_mut(); if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { @@ -623,14 +610,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.chat_state = crate::app::ChatState::Normal; app.needs_redraw = true; // ВАЖНО: перерисовываем UI } - Ok(Err(e)) => { - app.error_message = Some(format!( - "Редактирование (chat={}, msg={}): {}", - chat_id, msg_id.as_i64(), e - )); - } - Err(_) => { - app.error_message = Some("Таймаут редактирования".to_string()); + Err(e) => { + app.error_message = Some(e); } } } @@ -662,25 +643,23 @@ pub async fn handle(app: &mut App, key: KeyEvent) { .send_chat_action(ChatId::new(chat_id), ChatAction::Cancel) .await; - match timeout( + match with_timeout_msg( Duration::from_secs(5), app.td_client .send_message(ChatId::new(chat_id), text, reply_to_id, reply_info), + "Таймаут отправки", ) .await { - Ok(Ok(sent_msg)) => { + Ok(sent_msg) => { // Добавляем отправленное сообщение в список (с лимитом) app.td_client.push_message(sent_msg); // Сбрасываем скролл чтобы видеть новое сообщение app.message_scroll_offset = 0; } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); } - Err(_) => { - app.error_message = Some("Таймаут отправки".to_string()); - } } } } @@ -694,26 +673,27 @@ pub async fn handle(app: &mut App, key: KeyEvent) { if let Some(chat_id) = app.get_selected_chat_id() { app.status_message = Some("Загрузка сообщений...".to_string()); app.message_scroll_offset = 0; - match timeout( + match with_timeout_msg( Duration::from_secs(10), app.td_client.get_chat_history(ChatId::new(chat_id), 100), + "Таймаут загрузки сообщений", ) .await { - Ok(Ok(messages)) => { + Ok(messages) => { // Сохраняем загруженные сообщения *app.td_client.current_chat_messages_mut() = messages; // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории // Это предотвращает race condition с Update::NewMessage app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); // Загружаем недостающие reply info - let _ = timeout( + let _ = tokio::time::timeout( Duration::from_secs(5), app.td_client.fetch_missing_reply_info(), ) .await; // Загружаем последнее закреплённое сообщение - let _ = timeout( + let _ = tokio::time::timeout( Duration::from_secs(2), app.td_client.load_current_pinned_message(ChatId::new(chat_id)), ) @@ -722,14 +702,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.load_draft(); app.status_message = None; } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); app.status_message = None; } - Err(_) => { - app.error_message = Some("Таймаут загрузки сообщений".to_string()); - app.status_message = None; - } } } } @@ -823,14 +799,16 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.needs_redraw = true; // Запрашиваем доступные реакции - match timeout( + match with_timeout_msg( Duration::from_secs(5), app.td_client .get_message_available_reactions(chat_id, message_id), + "Таймаут загрузки реакций", ) .await { - Ok(Ok(reactions)) => { + Ok(reactions) => { + let reactions: Vec = reactions; if reactions.is_empty() { app.error_message = Some("Реакции недоступны для этого сообщения".to_string()); @@ -842,13 +820,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.needs_redraw = true; } } - Ok(Err(e)) => { - app.error_message = Some(format!("Ошибка загрузки реакций: {}", e)); - app.status_message = None; - app.needs_redraw = true; - } - Err(_) => { - app.error_message = Some("Таймаут загрузки реакций".to_string()); + Err(e) => { + app.error_message = Some(e); app.status_message = None; app.needs_redraw = true; } @@ -864,20 +837,21 @@ pub async fn handle(app: &mut App, key: KeyEvent) { if key.code == KeyCode::Char('u') && has_ctrl { if let Some(chat_id) = app.selected_chat_id { app.status_message = Some("Загрузка профиля...".to_string()); - match timeout(Duration::from_secs(5), app.td_client.get_profile_info(chat_id)).await + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.get_profile_info(chat_id), + "Таймаут загрузки профиля", + ) + .await { - Ok(Ok(profile)) => { + Ok(profile) => { app.enter_profile_mode(profile); app.status_message = None; } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); app.status_message = None; } - Err(_) => { - app.error_message = Some("Таймаут загрузки профиля".to_string()); - app.status_message = None; - } } } return; @@ -991,13 +965,14 @@ pub async fn handle(app: &mut App, key: KeyEvent) { if app.message_scroll_offset > app.td_client.current_chat_messages().len().saturating_sub(10) { - if let Ok(Ok(older)) = timeout( + if let Ok(older) = with_timeout( Duration::from_secs(3), app.td_client .load_older_messages(ChatId::new(chat_id), oldest_msg_id), ) .await { + let older: Vec = older; if !older.is_empty() { // Добавляем старые сообщения в начало let msgs = app.td_client.current_chat_messages_mut(); @@ -1033,7 +1008,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.selected_folder_id = Some(folder_id); // Загружаем чаты папки app.status_message = Some("Загрузка чатов папки...".to_string()); - let _ = timeout( + let _ = with_timeout( Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50), ) diff --git a/src/utils.rs b/src/utils/formatting.rs similarity index 91% rename from src/utils.rs rename to src/utils/formatting.rs index 9d25cba..d17623f 100644 --- a/src/utils.rs +++ b/src/utils/formatting.rs @@ -1,27 +1,3 @@ -use std::ffi::CString; -use std::os::raw::c_char; - -#[link(name = "tdjson")] -extern "C" { - fn td_execute(request: *const c_char) -> *const c_char; -} - -/// Отключаем логи TDLib синхронно, до создания клиента -pub fn disable_tdlib_logs() { - let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#; - let c_request = CString::new(request).unwrap(); - unsafe { - let _ = td_execute(c_request.as_ptr()); - } - - // Также перенаправляем логи в никуда - let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#; - let c_request2 = CString::new(request2).unwrap(); - unsafe { - let _ = td_execute(c_request2.as_ptr()); - } -} - /// Форматирование timestamp в время HH:MM с учётом timezone offset /// timezone_str: строка формата "+03:00" или "-05:00" pub fn format_timestamp_with_tz(timestamp: i32, timezone_str: &str) -> String { diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..373147f --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,7 @@ +pub mod formatting; +pub mod retry; +pub mod tdlib; + +pub use formatting::*; +pub use retry::{with_timeout, with_timeout_msg}; +pub use tdlib::*; diff --git a/src/utils/retry.rs b/src/utils/retry.rs new file mode 100644 index 0000000..72168fa --- /dev/null +++ b/src/utils/retry.rs @@ -0,0 +1,140 @@ +use std::future::Future; +use std::time::Duration; +use tokio::time::timeout; + +/// Выполняет операцию с таймаутом и возвращает результат. +/// +/// # Arguments +/// +/// * `duration` - Длительность таймаута +/// * `operation` - Асинхронная операция для выполнения +/// +/// # Returns +/// +/// * `Ok(T)` - если операция успешна +/// * `Err(String)` - если операция вернула ошибку или произошел таймаут +/// +/// # Examples +/// +/// ```ignore +/// let result = with_timeout( +/// Duration::from_secs(5), +/// client.load_chats(50) +/// ).await; +/// ``` +pub async fn with_timeout(duration: Duration, operation: F) -> Result +where + F: Future>, +{ + match timeout(duration, operation).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(e)) => Err(e), + Err(_) => Err("Операция превысила время ожидания".to_string()), + } +} + +/// Выполняет операцию с таймаутом и кастомным сообщением об ошибке таймаута. +/// +/// # Arguments +/// +/// * `duration` - Длительность таймаута +/// * `operation` - Асинхронная операция для выполнения +/// * `timeout_msg` - Сообщение об ошибке при таймауте +/// +/// # Returns +/// +/// * `Ok(T)` - если операция успешна +/// * `Err(String)` - если операция вернула ошибку или произошел таймаут +/// +/// # Examples +/// +/// ```ignore +/// let result = with_timeout_msg( +/// Duration::from_secs(5), +/// client.load_chats(50), +/// "Таймаут загрузки чатов" +/// ).await; +/// ``` +pub async fn with_timeout_msg( + duration: Duration, + operation: F, + timeout_msg: &str, +) -> Result +where + F: Future>, +{ + match timeout(duration, operation).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(e)) => Err(e), + Err(_) => Err(timeout_msg.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test] + async fn test_with_timeout_success() { + let result = with_timeout(Duration::from_secs(1), async { + Ok::<_, String>("success".to_string()) + }) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "success"); + } + + #[tokio::test] + async fn test_with_timeout_operation_error() { + let result = with_timeout(Duration::from_secs(1), async { + Err::("operation failed".to_string()) + }) + .await; + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "operation failed"); + } + + #[tokio::test] + async fn test_with_timeout_timeout_error() { + let result = with_timeout(Duration::from_millis(10), async { + tokio::time::sleep(Duration::from_millis(100)).await; + Ok::<_, String>("too slow".to_string()) + }) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("превысила время ожидания")); + } + + #[tokio::test] + async fn test_with_timeout_msg_success() { + let result = with_timeout_msg( + Duration::from_secs(1), + async { Ok::<_, String>("success".to_string()) }, + "Custom timeout", + ) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "success"); + } + + #[tokio::test] + async fn test_with_timeout_msg_timeout_error() { + let result = with_timeout_msg( + Duration::from_millis(10), + async { + tokio::time::sleep(Duration::from_millis(100)).await; + Ok::<_, String>("too slow".to_string()) + }, + "Таймаут загрузки", + ) + .await; + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "Таймаут загрузки"); + } +} diff --git a/src/utils/tdlib.rs b/src/utils/tdlib.rs new file mode 100644 index 0000000..681fe34 --- /dev/null +++ b/src/utils/tdlib.rs @@ -0,0 +1,23 @@ +use std::ffi::CString; +use std::os::raw::c_char; + +#[link(name = "tdjson")] +extern "C" { + fn td_execute(request: *const c_char) -> *const c_char; +} + +/// Отключаем логи TDLib синхронно, до создания клиента +pub fn disable_tdlib_logs() { + let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#; + let c_request = CString::new(request).unwrap(); + unsafe { + let _ = td_execute(c_request.as_ptr()); + } + + // Также перенаправляем логи в никуда + let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#; + let c_request2 = CString::new(request2).unwrap(); + unsafe { + let _ = td_execute(c_request2.as_ptr()); + } +}