pub mod keybindings; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; pub use keybindings::{Command, 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, /// Настройки desktop notifications. #[serde(default)] pub notifications: NotificationsConfig, } /// Общие настройки приложения. #[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, } /// Настройки desktop notifications. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotificationsConfig { /// Включить/выключить уведомления #[serde(default = "default_notifications_enabled")] pub enabled: bool, /// Уведомлять только при @упоминаниях #[serde(default)] pub only_mentions: bool, /// Показывать превью текста сообщения #[serde(default = "default_show_preview")] pub show_preview: bool, /// Продолжительность показа уведомления (миллисекунды) /// 0 = системное значение по умолчанию #[serde(default = "default_notification_timeout")] pub timeout_ms: i32, /// Уровень важности: "low", "normal", "critical" #[serde(default = "default_notification_urgency")] pub urgency: 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() } fn default_notifications_enabled() -> bool { true } fn default_show_preview() -> bool { true } fn default_notification_timeout() -> i32 { 5000 // 5 seconds } fn default_notification_urgency() -> String { "normal".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 NotificationsConfig { fn default() -> Self { Self { enabled: default_notifications_enabled(), only_mentions: false, show_preview: default_show_preview(), timeout_ms: default_notification_timeout(), urgency: default_notification_urgency(), } } } impl Default for Config { fn default() -> Self { Self { general: GeneralConfig::default(), colors: ColorsConfig::default(), keybindings: Keybindings::default(), notifications: NotificationsConfig::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 { dirs::config_dir().map(|mut path| { path.push("tele-tui"); path.push("config.toml"); path }) } /// Путь к директории конфигурации pub fn config_dir() -> Option { 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::(&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 { 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> { // 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 = None; let mut api_hash: Option = 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::().ok()?; Some((api_id, api_hash)) } } #[cfg(test)] mod tests { use super::*; use crossterm::event::{KeyCode, 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); } }