refactor: add modal/validation utils and partial App encapsulation

Quick wins refactoring (Variant 1):
- Created src/utils/modal_handler.rs (120+ lines)
  - 4 functions for modal handling (close, confirm, yes/no)
  - ModalAction enum for type-safe processing
  - English and Russian keyboard layout support
  - 4 unit tests
- Created src/utils/validation.rs (180+ lines)
  - 7 validation functions (empty, length, IDs, etc)
  - Covers all common validation patterns
  - 7 unit tests
- Partial App encapsulation:
  - Made config field private (readonly via app.config())
  - Added 30+ getter/setter methods
  - Updated ui/messages.rs to use config()
- Updated documentation:
  - REFACTORING_OPPORTUNITIES.md: #1 Complete, #5 Partial
  - CONTEXT.md: Added quick wins section

Tests: 563 passed, 0 failed

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-01 23:55:49 +03:00
parent e690acfb09
commit dff0897da4
7 changed files with 631 additions and 19 deletions

View File

@@ -1,7 +1,11 @@
pub mod formatting;
pub mod modal_handler;
pub mod retry;
pub mod tdlib;
pub mod validation;
pub use formatting::*;
pub use modal_handler::*;
pub use retry::{with_timeout, with_timeout_msg};
pub use tdlib::*;
pub use validation::*;

184
src/utils/modal_handler.rs Normal file
View File

@@ -0,0 +1,184 @@
//! Modal dialog utilities
//!
//! Переиспользуемая логика для обработки модальных окон (диалогов).
use crossterm::event::KeyCode;
/// Результат обработки клавиши в модальном окне.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModalAction {
/// Закрыть модалку (Escape была нажата)
Close,
/// Подтвердить действие (Enter была нажата)
Confirm,
/// Продолжить обработку ввода (другая клавиша)
Continue,
}
/// Обрабатывает стандартные клавиши для модальных окон.
///
/// Проверяет клавиши Escape (закрыть) и Enter (подтвердить).
/// Если нажата другая клавиша, возвращает `Continue`.
///
/// # Arguments
///
/// * `key_code` - код нажатой клавиши
///
/// # Returns
///
/// * `ModalAction::Close` - если нажата Escape
/// * `ModalAction::Confirm` - если нажата Enter
/// * `ModalAction::Continue` - для других клавиш
///
/// # Examples
///
/// ```
/// use crossterm::event::KeyCode;
/// use tele_tui::utils::modal_handler::{handle_modal_key, ModalAction};
///
/// assert_eq!(handle_modal_key(KeyCode::Esc), ModalAction::Close);
/// assert_eq!(handle_modal_key(KeyCode::Enter), ModalAction::Confirm);
/// assert_eq!(handle_modal_key(KeyCode::Char('a')), ModalAction::Continue);
/// ```
pub fn handle_modal_key(key_code: KeyCode) -> ModalAction {
match key_code {
KeyCode::Esc => ModalAction::Close,
KeyCode::Enter => ModalAction::Confirm,
_ => ModalAction::Continue,
}
}
/// Проверяет, нужно ли закрыть модалку (нажата Escape).
///
/// # Examples
///
/// ```
/// use crossterm::event::KeyCode;
/// use tele_tui::utils::modal_handler::should_close_modal;
///
/// assert!(should_close_modal(KeyCode::Esc));
/// assert!(!should_close_modal(KeyCode::Enter));
/// assert!(!should_close_modal(KeyCode::Char('q')));
/// ```
pub fn should_close_modal(key_code: KeyCode) -> bool {
matches!(key_code, KeyCode::Esc)
}
/// Проверяет, нужно ли подтвердить действие в модалке (нажата Enter).
///
/// # Examples
///
/// ```
/// use crossterm::event::KeyCode;
/// use tele_tui::utils::modal_handler::should_confirm_modal;
///
/// assert!(should_confirm_modal(KeyCode::Enter));
/// assert!(!should_confirm_modal(KeyCode::Esc));
/// assert!(!should_confirm_modal(KeyCode::Char('y')));
/// ```
pub fn should_confirm_modal(key_code: KeyCode) -> bool {
matches!(key_code, KeyCode::Enter)
}
/// Обрабатывает клавиши для подтверждения Yes/No.
///
/// Поддерживает:
/// - `y` / `Y` / `д` / `Д` - да (confirm)
/// - `n` / `N` / `т` / `Т` - нет (close)
/// - `Enter` - подтвердить (confirm)
/// - `Esc` - отменить (close)
///
/// # Arguments
///
/// * `key_code` - код нажатой клавиши
///
/// # Returns
///
/// * `Some(true)` - подтверждение (yes/Enter)
/// * `Some(false)` - отмена (no/Escape)
/// * `None` - другая клавиша (продолжить ввод)
///
/// # Examples
///
/// ```
/// use crossterm::event::KeyCode;
/// use tele_tui::utils::modal_handler::handle_yes_no;
///
/// assert_eq!(handle_yes_no(KeyCode::Char('y')), Some(true));
/// assert_eq!(handle_yes_no(KeyCode::Char('Y')), Some(true));
/// assert_eq!(handle_yes_no(KeyCode::Char('д')), Some(true)); // русская 'y'
/// assert_eq!(handle_yes_no(KeyCode::Enter), Some(true));
///
/// assert_eq!(handle_yes_no(KeyCode::Char('n')), Some(false));
/// assert_eq!(handle_yes_no(KeyCode::Char('т')), Some(false)); // русская 'n'
/// assert_eq!(handle_yes_no(KeyCode::Esc), Some(false));
///
/// assert_eq!(handle_yes_no(KeyCode::Char('a')), None);
/// ```
pub fn handle_yes_no(key_code: KeyCode) -> Option<bool> {
match key_code {
// Yes - подтверждение (английская и русская раскладка)
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('д') | KeyCode::Char('Д') => {
Some(true)
}
KeyCode::Enter => Some(true),
// No - отмена (английская и русская раскладка)
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('т') | KeyCode::Char('Т') => {
Some(false)
}
KeyCode::Esc => Some(false),
// Другие клавиши - продолжить
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_handle_modal_key() {
assert_eq!(handle_modal_key(KeyCode::Esc), ModalAction::Close);
assert_eq!(handle_modal_key(KeyCode::Enter), ModalAction::Confirm);
assert_eq!(handle_modal_key(KeyCode::Char('a')), ModalAction::Continue);
assert_eq!(handle_modal_key(KeyCode::Up), ModalAction::Continue);
}
#[test]
fn test_should_close_modal() {
assert!(should_close_modal(KeyCode::Esc));
assert!(!should_close_modal(KeyCode::Enter));
assert!(!should_close_modal(KeyCode::Char('q')));
}
#[test]
fn test_should_confirm_modal() {
assert!(should_confirm_modal(KeyCode::Enter));
assert!(!should_confirm_modal(KeyCode::Esc));
assert!(!should_confirm_modal(KeyCode::Char('y')));
}
#[test]
fn test_handle_yes_no() {
// Yes variants
assert_eq!(handle_yes_no(KeyCode::Char('y')), Some(true));
assert_eq!(handle_yes_no(KeyCode::Char('Y')), Some(true));
assert_eq!(handle_yes_no(KeyCode::Char('д')), Some(true)); // Russian
assert_eq!(handle_yes_no(KeyCode::Char('Д')), Some(true)); // Russian
assert_eq!(handle_yes_no(KeyCode::Enter), Some(true));
// No variants
assert_eq!(handle_yes_no(KeyCode::Char('n')), Some(false));
assert_eq!(handle_yes_no(KeyCode::Char('N')), Some(false));
assert_eq!(handle_yes_no(KeyCode::Char('т')), Some(false)); // Russian
assert_eq!(handle_yes_no(KeyCode::Char('Т')), Some(false)); // Russian
assert_eq!(handle_yes_no(KeyCode::Esc), Some(false));
// Other keys
assert_eq!(handle_yes_no(KeyCode::Char('a')), None);
assert_eq!(handle_yes_no(KeyCode::Up), None);
assert_eq!(handle_yes_no(KeyCode::Char(' ')), None);
}
}

191
src/utils/validation.rs Normal file
View File

@@ -0,0 +1,191 @@
//! Input validation utilities
//!
//! Переиспользуемые валидаторы для проверки пользовательского ввода.
use crate::types::{ChatId, MessageId, UserId};
/// Проверяет, что строка не пустая (после trim).
///
/// # Examples
///
/// ```
/// use tele_tui::utils::validation::is_non_empty;
///
/// assert!(is_non_empty("hello"));
/// assert!(is_non_empty(" text "));
/// assert!(!is_non_empty(""));
/// assert!(!is_non_empty(" "));
/// ```
pub fn is_non_empty(text: &str) -> bool {
!text.trim().is_empty()
}
/// Проверяет, что текст не превышает максимальную длину.
///
/// # Arguments
///
/// * `text` - текст для проверки
/// * `max_length` - максимальная длина в символах
///
/// # Examples
///
/// ```
/// use tele_tui::utils::validation::is_within_length;
///
/// assert!(is_within_length("hello", 10));
/// assert!(!is_within_length("very long text here", 5));
/// ```
pub fn is_within_length(text: &str, max_length: usize) -> bool {
text.chars().count() <= max_length
}
/// Проверяет валидность ID чата (не нулевой).
///
/// # Examples
///
/// ```
/// use tele_tui::types::ChatId;
/// use tele_tui::utils::validation::is_valid_chat_id;
///
/// assert!(is_valid_chat_id(ChatId::new(123)));
/// assert!(!is_valid_chat_id(ChatId::new(0)));
/// assert!(!is_valid_chat_id(ChatId::new(-1)));
/// ```
pub fn is_valid_chat_id(chat_id: ChatId) -> bool {
chat_id.as_i64() > 0
}
/// Проверяет валидность ID сообщения (не нулевой).
///
/// # Examples
///
/// ```
/// use tele_tui::types::MessageId;
/// use tele_tui::utils::validation::is_valid_message_id;
///
/// assert!(is_valid_message_id(MessageId::new(456)));
/// assert!(!is_valid_message_id(MessageId::new(0)));
/// ```
pub fn is_valid_message_id(message_id: MessageId) -> bool {
message_id.as_i64() > 0
}
/// Проверяет валидность ID пользователя (не нулевой).
///
/// # Examples
///
/// ```
/// use tele_tui::types::UserId;
/// use tele_tui::utils::validation::is_valid_user_id;
///
/// assert!(is_valid_user_id(UserId::new(789)));
/// assert!(!is_valid_user_id(UserId::new(0)));
/// ```
pub fn is_valid_user_id(user_id: UserId) -> bool {
user_id.as_i64() > 0
}
/// Проверяет, что вектор не пустой.
///
/// # Examples
///
/// ```
/// use tele_tui::utils::validation::has_items;
///
/// assert!(has_items(&vec![1, 2, 3]));
/// assert!(!has_items::<i32>(&vec![]));
/// ```
pub fn has_items<T>(items: &[T]) -> bool {
!items.is_empty()
}
/// Комбинированная валидация текстового ввода:
/// - Не пустой (после trim)
/// - В пределах максимальной длины
///
/// # Examples
///
/// ```
/// use tele_tui::utils::validation::validate_text_input;
///
/// assert!(validate_text_input("hello", 100).is_ok());
/// assert!(validate_text_input("", 100).is_err());
/// assert!(validate_text_input(" ", 100).is_err());
/// assert!(validate_text_input("very long text", 5).is_err());
/// ```
pub fn validate_text_input(text: &str, max_length: usize) -> Result<(), String> {
if !is_non_empty(text) {
return Err("Text cannot be empty".to_string());
}
if !is_within_length(text, max_length) {
return Err(format!(
"Text exceeds maximum length of {} characters",
max_length
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_non_empty() {
assert!(is_non_empty("hello"));
assert!(is_non_empty(" text "));
assert!(!is_non_empty(""));
assert!(!is_non_empty(" "));
assert!(!is_non_empty("\t\n"));
}
#[test]
fn test_is_within_length() {
assert!(is_within_length("hello", 10));
assert!(is_within_length("hello", 5));
assert!(!is_within_length("hello", 4));
assert!(is_within_length("", 0));
}
#[test]
fn test_is_valid_chat_id() {
assert!(is_valid_chat_id(ChatId::new(123)));
assert!(is_valid_chat_id(ChatId::new(999999)));
assert!(!is_valid_chat_id(ChatId::new(0)));
assert!(!is_valid_chat_id(ChatId::new(-1)));
}
#[test]
fn test_is_valid_message_id() {
assert!(is_valid_message_id(MessageId::new(456)));
assert!(!is_valid_message_id(MessageId::new(0)));
assert!(!is_valid_message_id(MessageId::new(-1)));
}
#[test]
fn test_is_valid_user_id() {
assert!(is_valid_user_id(UserId::new(789)));
assert!(!is_valid_user_id(UserId::new(0)));
}
#[test]
fn test_has_items() {
assert!(has_items(&vec![1, 2, 3]));
assert!(has_items(&vec!["a"]));
assert!(!has_items::<i32>(&vec![]));
}
#[test]
fn test_validate_text_input() {
// Valid
assert!(validate_text_input("hello", 100).is_ok());
assert!(validate_text_input("test message", 20).is_ok());
// Empty
assert!(validate_text_input("", 100).is_err());
assert!(validate_text_input(" ", 100).is_err());
// Too long
assert!(validate_text_input("very long text", 5).is_err());
}
}