refactor: extract state modules and services from monolithic files
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
Извлечены state модули и сервисы из монолитных файлов для улучшения структуры: State модули: - auth_state.rs: состояние авторизации - chat_list_state.rs: состояние списка чатов - compose_state.rs: состояние ввода сообщений - message_view_state.rs: состояние просмотра сообщений - ui_state.rs: UI состояние Сервисы и утилиты: - chat_filter.rs: централизованная фильтрация чатов (470+ строк) - message_service.rs: сервис работы с сообщениями (17KB) - key_handler.rs: trait для обработки клавиш (380+ строк) Config модуль: - config.rs -> config/mod.rs: основной конфиг - config/keybindings.rs: настраиваемые горячие клавиши (420+ строк) Тесты: 626 passed ✅ Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
588
src/config/mod.rs
Normal file
588
src/config/mod.rs
Normal file
@@ -0,0 +1,588 @@
|
||||
pub mod keybindings;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use keybindings::{Command, KeyBinding, Keybindings};
|
||||
|
||||
/// Главная конфигурация приложения.
|
||||
///
|
||||
/// Загружается из `~/.config/tele-tui/config.toml` и содержит настройки
|
||||
/// общего поведения, цветовой схемы и горячих клавиш.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузка конфигурации
|
||||
/// let config = Config::load();
|
||||
///
|
||||
/// // Доступ к настройкам
|
||||
/// println!("Timezone: {}", config.general.timezone);
|
||||
/// println!("Incoming color: {}", config.colors.incoming_message);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Общие настройки (timezone и т.д.).
|
||||
#[serde(default)]
|
||||
pub general: GeneralConfig,
|
||||
|
||||
/// Цветовая схема интерфейса.
|
||||
#[serde(default)]
|
||||
pub colors: ColorsConfig,
|
||||
|
||||
/// Горячие клавиши.
|
||||
#[serde(default)]
|
||||
pub keybindings: Keybindings,
|
||||
}
|
||||
|
||||
/// Общие настройки приложения.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneralConfig {
|
||||
/// Часовой пояс в формате "+03:00" или "-05:00"
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
}
|
||||
|
||||
/// Цветовая схема интерфейса.
|
||||
///
|
||||
/// Поддерживаемые цвета: red, green, blue, yellow, cyan, magenta,
|
||||
/// white, black, gray/grey, а также light-варианты (lightred, lightgreen и т.д.).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ColorsConfig {
|
||||
/// Цвет входящих сообщений (white, gray, cyan и т.д.)
|
||||
#[serde(default = "default_incoming_color")]
|
||||
pub incoming_message: String,
|
||||
|
||||
/// Цвет исходящих сообщений
|
||||
#[serde(default = "default_outgoing_color")]
|
||||
pub outgoing_message: String,
|
||||
|
||||
/// Цвет выбранного сообщения
|
||||
#[serde(default = "default_selected_color")]
|
||||
pub selected_message: String,
|
||||
|
||||
/// Цвет своих реакций
|
||||
#[serde(default = "default_reaction_chosen_color")]
|
||||
pub reaction_chosen: String,
|
||||
|
||||
/// Цвет чужих реакций
|
||||
#[serde(default = "default_reaction_other_color")]
|
||||
pub reaction_other: String,
|
||||
}
|
||||
|
||||
// Дефолтные значения
|
||||
fn default_timezone() -> String {
|
||||
"+03:00".to_string()
|
||||
}
|
||||
|
||||
fn default_incoming_color() -> String {
|
||||
"white".to_string()
|
||||
}
|
||||
|
||||
fn default_outgoing_color() -> String {
|
||||
"green".to_string()
|
||||
}
|
||||
|
||||
fn default_selected_color() -> String {
|
||||
"yellow".to_string()
|
||||
}
|
||||
|
||||
fn default_reaction_chosen_color() -> String {
|
||||
"yellow".to_string()
|
||||
}
|
||||
|
||||
fn default_reaction_other_color() -> String {
|
||||
"gray".to_string()
|
||||
}
|
||||
|
||||
impl Default for GeneralConfig {
|
||||
fn default() -> Self {
|
||||
Self { timezone: default_timezone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ColorsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
incoming_message: default_incoming_color(),
|
||||
outgoing_message: default_outgoing_color(),
|
||||
selected_message: default_selected_color(),
|
||||
reaction_chosen: default_reaction_chosen_color(),
|
||||
reaction_other: default_reaction_other_color(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
general: GeneralConfig::default(),
|
||||
colors: ColorsConfig::default(),
|
||||
keybindings: Keybindings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Валидация конфигурации
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Проверка timezone
|
||||
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
|
||||
return Err(format!(
|
||||
"Invalid timezone (must start with + or -): {}",
|
||||
self.general.timezone
|
||||
));
|
||||
}
|
||||
|
||||
// Проверка цветов
|
||||
let valid_colors = [
|
||||
"black",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"gray",
|
||||
"grey",
|
||||
"white",
|
||||
"darkgray",
|
||||
"darkgrey",
|
||||
"lightred",
|
||||
"lightgreen",
|
||||
"lightyellow",
|
||||
"lightblue",
|
||||
"lightmagenta",
|
||||
"lightcyan",
|
||||
];
|
||||
|
||||
for color_name in [
|
||||
&self.colors.incoming_message,
|
||||
&self.colors.outgoing_message,
|
||||
&self.colors.selected_message,
|
||||
&self.colors.reaction_chosen,
|
||||
&self.colors.reaction_other,
|
||||
] {
|
||||
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
|
||||
return Err(format!("Invalid color: {}", color_name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Возвращает путь к конфигурационному файлу.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
|
||||
/// `None` - Не удалось определить директорию конфигурации
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|mut path| {
|
||||
path.push("tele-tui");
|
||||
path.push("config.toml");
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
/// Путь к директории конфигурации
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|mut path| {
|
||||
path.push("tele-tui");
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
/// Загружает конфигурацию из файла.
|
||||
///
|
||||
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
|
||||
/// Если файл не существует, создаёт дефолтный.
|
||||
/// Если файл невалиден, возвращает дефолтные значения.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Всегда возвращает валидную конфигурацию.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let config = Config::load();
|
||||
/// ```
|
||||
pub fn load() -> Self {
|
||||
let config_path = match Self::config_path() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
tracing::warn!("Could not determine config directory, using defaults");
|
||||
return Self::default();
|
||||
}
|
||||
};
|
||||
|
||||
if !config_path.exists() {
|
||||
// Создаём дефолтный конфиг при первом запуске
|
||||
let default_config = Self::default();
|
||||
if let Err(e) = default_config.save() {
|
||||
tracing::warn!("Could not create default config: {}", e);
|
||||
}
|
||||
return default_config;
|
||||
}
|
||||
|
||||
match fs::read_to_string(&config_path) {
|
||||
Ok(content) => match toml::from_str::<Config>(&content) {
|
||||
Ok(config) => {
|
||||
// Валидируем загруженный конфиг
|
||||
if let Err(e) = config.validate() {
|
||||
tracing::error!("Config validation error: {}", e);
|
||||
tracing::warn!("Using default configuration instead");
|
||||
Self::default()
|
||||
} else {
|
||||
config
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not parse config file: {}", e);
|
||||
Self::default()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not read config file: {}", e);
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет конфигурацию в файл.
|
||||
///
|
||||
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Конфиг сохранен
|
||||
/// * `Err(String)` - Ошибка сохранения
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let config_dir =
|
||||
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
|
||||
|
||||
// Создаём директорию если её нет
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Could not create config directory: {}", e))?;
|
||||
|
||||
let config_path = config_dir.join("config.toml");
|
||||
|
||||
let toml_string = toml::to_string_pretty(self)
|
||||
.map_err(|e| format!("Could not serialize config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, toml_string)
|
||||
.map_err(|e| format!("Could not write config file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Парсит строку цвета в `ratatui::style::Color`.
|
||||
///
|
||||
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
|
||||
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `color_str` - Название цвета (case-insensitive)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let color = config.parse_color("red");
|
||||
/// let color = config.parse_color("LightBlue");
|
||||
/// ```
|
||||
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
|
||||
use ratatui::style::Color;
|
||||
|
||||
match color_str.to_lowercase().as_str() {
|
||||
"black" => Color::Black,
|
||||
"red" => Color::Red,
|
||||
"green" => Color::Green,
|
||||
"yellow" => Color::Yellow,
|
||||
"blue" => Color::Blue,
|
||||
"magenta" => Color::Magenta,
|
||||
"cyan" => Color::Cyan,
|
||||
"gray" | "grey" => Color::Gray,
|
||||
"white" => Color::White,
|
||||
"darkgray" | "darkgrey" => Color::DarkGray,
|
||||
"lightred" => Color::LightRed,
|
||||
"lightgreen" => Color::LightGreen,
|
||||
"lightyellow" => Color::LightYellow,
|
||||
"lightblue" => Color::LightBlue,
|
||||
"lightmagenta" => Color::LightMagenta,
|
||||
"lightcyan" => Color::LightCyan,
|
||||
_ => Color::White, // fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Путь к файлу credentials
|
||||
pub fn credentials_path() -> Option<PathBuf> {
|
||||
Self::config_dir().map(|dir| dir.join("credentials"))
|
||||
}
|
||||
|
||||
/// Загружает API_ID и API_HASH для Telegram.
|
||||
///
|
||||
/// Ищет credentials в следующем порядке:
|
||||
/// 1. `~/.config/tele-tui/credentials` файл
|
||||
/// 2. Переменные окружения `API_ID` и `API_HASH`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
|
||||
/// * `Err(String)` - Ошибка с инструкциями по настройке
|
||||
///
|
||||
/// # Credentials Format
|
||||
///
|
||||
/// Файл `~/.config/tele-tui/credentials`:
|
||||
/// ```text
|
||||
/// API_ID=12345
|
||||
/// API_HASH=your_api_hash_here
|
||||
/// ```
|
||||
pub fn load_credentials() -> Result<(i32, String), String> {
|
||||
use std::env;
|
||||
|
||||
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
|
||||
if let Some(credentials) = Self::load_credentials_from_file() {
|
||||
return Ok(credentials);
|
||||
}
|
||||
|
||||
// 2. Пробуем загрузить из переменных окружения (.env)
|
||||
if let Some(credentials) = Self::load_credentials_from_env() {
|
||||
return Ok(credentials);
|
||||
}
|
||||
|
||||
// 3. Не нашли credentials - возвращаем инструкции
|
||||
let credentials_path = Self::credentials_path()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
|
||||
|
||||
Err(format!(
|
||||
"Telegram API credentials not found!\n\n\
|
||||
Please create a file at:\n {}\n\n\
|
||||
With the following content:\n\
|
||||
API_ID=your_api_id\n\
|
||||
API_HASH=your_api_hash\n\n\
|
||||
You can get API credentials at: https://my.telegram.org/apps\n\n\
|
||||
Alternatively, you can create a .env file in the current directory.",
|
||||
credentials_path
|
||||
))
|
||||
}
|
||||
|
||||
/// Загружает credentials из файла ~/.config/tele-tui/credentials
|
||||
fn load_credentials_from_file() -> Option<(i32, String)> {
|
||||
let cred_path = Self::credentials_path()?;
|
||||
|
||||
if !cred_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&cred_path).ok()?;
|
||||
let mut api_id: Option<i32> = None;
|
||||
let mut api_hash: Option<String> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (key, value) = line.split_once('=')?;
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
match key {
|
||||
"API_ID" => api_id = value.parse().ok(),
|
||||
"API_HASH" => api_hash = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some((api_id?, api_hash?))
|
||||
}
|
||||
|
||||
/// Загружает credentials из переменных окружения (.env)
|
||||
fn load_credentials_from_env() -> Option<(i32, String)> {
|
||||
use std::env;
|
||||
|
||||
let api_id_str = env::var("API_ID").ok()?;
|
||||
let api_hash = env::var("API_HASH").ok()?;
|
||||
let api_id = api_id_str.parse::<i32>().ok()?;
|
||||
|
||||
Some((api_id, api_hash))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyEvent, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn test_config_default_includes_keybindings() {
|
||||
let config = Config::default();
|
||||
let keybindings = &config.keybindings;
|
||||
|
||||
// Test that keybindings exist for common commands
|
||||
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
|
||||
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
|
||||
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
|
||||
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid() {
|
||||
let config = Config::default();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_timezone_no_sign() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "03:00".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("timezone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_negative_timezone() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "-05:00".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_positive_timezone() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "+09:00".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_incoming() {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = "rainbow".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_outgoing() {
|
||||
let mut config = Config::default();
|
||||
config.colors.outgoing_message = "purple".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_selected() {
|
||||
let mut config = Config::default();
|
||||
config.colors.selected_message = "pink".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_all_standard_colors() {
|
||||
let colors = [
|
||||
"black", "red", "green", "yellow", "blue", "magenta",
|
||||
"cyan", "gray", "grey", "white", "darkgray", "darkgrey",
|
||||
"lightred", "lightgreen", "lightyellow", "lightblue",
|
||||
"lightmagenta", "lightcyan"
|
||||
];
|
||||
|
||||
for color in colors {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = color.to_string();
|
||||
config.colors.outgoing_message = color.to_string();
|
||||
config.colors.selected_message = color.to_string();
|
||||
config.colors.reaction_chosen = color.to_string();
|
||||
config.colors.reaction_other = color.to_string();
|
||||
|
||||
assert!(
|
||||
config.validate().is_ok(),
|
||||
"Color '{}' should be valid",
|
||||
color
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_case_insensitive_colors() {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = "RED".to_string();
|
||||
config.colors.outgoing_message = "Green".to_string();
|
||||
config.colors.selected_message = "YELLOW".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_standard() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("red"), Color::Red);
|
||||
assert_eq!(config.parse_color("green"), Color::Green);
|
||||
assert_eq!(config.parse_color("blue"), Color::Blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_light_variants() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("lightred"), Color::LightRed);
|
||||
assert_eq!(config.parse_color("lightgreen"), Color::LightGreen);
|
||||
assert_eq!(config.parse_color("lightblue"), Color::LightBlue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_gray_variants() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("gray"), Color::Gray);
|
||||
assert_eq!(config.parse_color("grey"), Color::Gray);
|
||||
assert_eq!(config.parse_color("darkgray"), Color::DarkGray);
|
||||
assert_eq!(config.parse_color("darkgrey"), Color::DarkGray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_case_insensitive() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("RED"), Color::Red);
|
||||
assert_eq!(config.parse_color("Green"), Color::Green);
|
||||
assert_eq!(config.parse_color("LIGHTBLUE"), Color::LightBlue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_invalid_fallback() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
// Invalid colors should fallback to White
|
||||
assert_eq!(config.parse_color("rainbow"), Color::White);
|
||||
assert_eq!(config.parse_color("purple"), Color::White);
|
||||
assert_eq!(config.parse_color("unknown"), Color::White);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user