Split core and TUI crates

This commit is contained in:
Mikhail Kilin
2026-05-20 00:31:18 +03:00
parent 91a8700b8e
commit eefac431e5
238 changed files with 624 additions and 191 deletions

View File

@@ -0,0 +1,282 @@
#[cfg(test)]
use chrono::FixedOffset;
use chrono::{DateTime, Local, NaiveDate, Utc};
use std::time::{SystemTime, UNIX_EPOCH};
pub trait LocalTimeSource {
fn now_date(&self) -> NaiveDate;
fn now_timestamp(&self) -> i32;
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String>;
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate>;
}
pub struct SystemLocalTime;
impl LocalTimeSource for SystemLocalTime {
fn now_date(&self) -> NaiveDate {
Local::now().date_naive()
}
fn now_timestamp(&self) -> i32 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32
}
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&Local).format(format).to_string())
}
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&Local).date_naive())
}
}
#[derive(Debug, Clone)]
#[cfg(test)]
pub struct FixedLocalTime {
offset: FixedOffset,
now: DateTime<FixedOffset>,
}
#[cfg(test)]
impl FixedLocalTime {
fn new(offset: FixedOffset, now_timestamp: i32) -> Self {
let now = DateTime::<Utc>::from_timestamp(now_timestamp as i64, 0)
.expect("valid fixed timestamp")
.with_timezone(&offset);
Self { offset, now }
}
}
#[cfg(test)]
impl LocalTimeSource for FixedLocalTime {
fn now_date(&self) -> NaiveDate {
self.now.date_naive()
}
fn now_timestamp(&self) -> i32 {
self.now.timestamp() as i32
}
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&self.offset).format(format).to_string())
}
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&self.offset).date_naive())
}
}
fn system_time() -> SystemLocalTime {
SystemLocalTime
}
/// Форматирование timestamp во время HH:MM в системной таймзоне.
pub fn format_timestamp(timestamp: i32) -> String {
format_timestamp_with(timestamp, &system_time())
}
pub fn format_timestamp_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
time.format_timestamp(timestamp, "%H:%M")
.unwrap_or_else(|| "00:00".to_string())
}
/// Форматирование timestamp в дату для разделителя.
pub fn format_date(timestamp: i32) -> String {
format_date_with(timestamp, &system_time())
}
pub fn format_date_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
let Some(msg_day) = time.date_for_timestamp(timestamp) else {
return "01.01.1970".to_string();
};
let today = time.now_date();
if msg_day == today {
"Сегодня".to_string()
} else if Some(msg_day) == today.pred_opt() {
"Вчера".to_string()
} else {
time.format_timestamp(timestamp, "%d.%m.%Y")
.unwrap_or_else(|| "01.01.1970".to_string())
}
}
/// Получить день из timestamp для группировки.
/// Возвращает число дней с 1970-01-01 в системной таймзоне.
#[allow(dead_code)]
pub fn get_day(timestamp: i32) -> i64 {
get_day_with(timestamp, &system_time())
}
#[allow(dead_code)]
pub fn get_day_with(timestamp: i32, time: &impl LocalTimeSource) -> i64 {
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date");
time.date_for_timestamp(timestamp)
.map(|date| date.signed_duration_since(epoch).num_days())
.unwrap_or(0)
}
/// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM) в системной таймзоне.
pub fn format_datetime(timestamp: i32) -> String {
format_datetime_with(timestamp, &system_time())
}
pub fn format_datetime_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
time.format_timestamp(timestamp, "%d.%m.%Y %H:%M")
.unwrap_or_else(|| "01.01.1970 00:00".to_string())
}
/// Форматирование "был(а) онлайн" из timestamp
pub fn format_was_online(timestamp: i32) -> String {
format_was_online_with(timestamp, &system_time())
}
pub fn format_was_online_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
let now = time.now_timestamp();
let diff = now - timestamp;
if diff < 60 {
"был(а) только что".to_string()
} else if diff < 3600 {
let mins = diff / 60;
format!("был(а) {} мин. назад", mins)
} else if diff < 86400 {
let hours = diff / 3600;
format!("был(а) {} ч. назад", hours)
} else {
// Показываем локальную дату
let datetime = time
.format_timestamp(timestamp, "%d.%m %H:%M")
.unwrap_or_else(|| "давно".to_string());
format!("был(а) {}", datetime)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_time() -> FixedLocalTime {
FixedLocalTime::new(
FixedOffset::east_opt(3 * 3600).unwrap(),
1_640_448_000, // 25.12.2021 03:00:00 +03:00
)
}
#[test]
fn test_format_timestamp_uses_supplied_timezone() {
let timestamp = 1640000000;
assert_eq!(format_timestamp_with(timestamp, &fixed_time()), "14:33");
}
#[test]
fn test_get_day() {
let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400);
assert_eq!(get_day_with(0, &time), 0);
assert_eq!(get_day_with(86400, &time), 1);
}
#[test]
fn test_get_day_grouping() {
let time = fixed_time();
let msg1 = 1640000000;
let msg2 = msg1 + 3600;
assert_eq!(get_day_with(msg1, &time), get_day_with(msg2, &time));
let msg3 = msg1 + 172800;
assert_ne!(get_day_with(msg1, &time), get_day_with(msg3, &time));
}
#[test]
fn test_format_datetime() {
let timestamp = 1640000000;
assert_eq!(format_datetime_with(timestamp, &fixed_time()), "20.12.2021 14:33");
}
#[test]
fn test_format_date_today() {
let time = fixed_time();
let result = format_date_with(time.now_timestamp(), &time);
assert_eq!(result, "Сегодня");
}
#[test]
fn test_format_date_yesterday() {
let time = fixed_time();
let yesterday = time.now_timestamp() - 86400;
let result = format_date_with(yesterday, &time);
assert_eq!(result, "Вчера");
}
#[test]
fn test_format_date_old() {
let old_timestamp = 1640000000;
assert_eq!(format_date_with(old_timestamp, &fixed_time()), "20.12.2021");
}
#[test]
fn test_format_date_epoch() {
let epoch = 0;
let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400);
let result = format_date_with(epoch, &time);
assert!(result.contains('.'));
assert!(result.contains("1970"));
}
#[test]
fn test_format_was_online_just_now() {
let time = fixed_time();
let now = time.now_timestamp();
let recent = now - 30;
let result = format_was_online_with(recent, &time);
assert_eq!(result, "был(а) только что");
}
#[test]
fn test_format_was_online_minutes_ago() {
let time = fixed_time();
let now = time.now_timestamp();
let mins_ago = now - (15 * 60);
let result = format_was_online_with(mins_ago, &time);
assert_eq!(result, "был(а) 15 мин. назад");
}
#[test]
fn test_format_was_online_hours_ago() {
let time = fixed_time();
let now = time.now_timestamp();
let hours_ago = now - (5 * 3600);
let result = format_was_online_with(hours_ago, &time);
assert_eq!(result, "был(а) 5 ч. назад");
}
#[test]
fn test_format_was_online_days_ago() {
let time = fixed_time();
let now = time.now_timestamp();
let days_ago = now - (3 * 86400);
let result = format_was_online_with(days_ago, &time);
assert!(result.starts_with("был(а)"));
assert!(result.contains('.') || result.contains(':'));
}
#[test]
fn test_format_was_online_very_old() {
let old = 1577836800;
let result = format_was_online_with(old, &fixed_time());
assert!(result.starts_with("был(а)"));
assert!(result.contains('.'));
}
}

View File

@@ -0,0 +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::*; // Используется через явный import
pub use retry::{with_timeout, with_timeout_ignore, with_timeout_msg};
pub use tdlib::*;
pub use validation::*;

View File

@@ -0,0 +1,86 @@
//! Modal dialog utilities
//!
//! Переиспользуемая логика для обработки модальных окон (диалогов).
use crossterm::event::KeyCode;
/// Обрабатывает клавиши для подтверждения 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_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);
}
}

View File

@@ -0,0 +1,167 @@
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<F, T>(duration: Duration, operation: F) -> Result<T, String>
where
F: Future<Output = Result<T, String>>,
{
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<F, T>(
duration: Duration,
operation: F,
timeout_msg: &str,
) -> Result<T, String>
where
F: Future<Output = Result<T, String>>,
{
match timeout(duration, operation).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(e)) => Err(e),
Err(_) => Err(timeout_msg.to_string()),
}
}
/// Выполняет операцию с таймаутом, игнорируя результат и ошибки.
///
/// Используется для не критичных операций (например, загрузка дополнительных данных),
/// где таймаут или ошибка не должны прерывать основной flow.
///
/// Работает как с Result<T, E>, так и с void операциями.
///
/// # Arguments
///
/// * `duration` - Длительность таймаута
/// * `operation` - Асинхронная операция для выполнения
///
/// # Examples
///
/// ```ignore
/// // Загружаем reply info, но не ждём если долго
/// with_timeout_ignore(
/// Duration::from_secs(5),
/// client.fetch_missing_reply_info()
/// ).await;
/// ```
pub async fn with_timeout_ignore<F>(duration: Duration, operation: F)
where
F: Future,
{
let _ = timeout(duration, operation).await;
}
#[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::<String, _>("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(), "Таймаут загрузки");
}
}

View File

@@ -0,0 +1,25 @@
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}"#;
if let Ok(c_request) = CString::new(request) {
unsafe {
let _ = td_execute(c_request.as_ptr());
}
}
// Также перенаправляем логи в никуда
let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#;
if let Ok(c_request2) = CString::new(request2) {
unsafe {
let _ = td_execute(c_request2.as_ptr());
}
}
}

View File

@@ -0,0 +1,33 @@
//! Input validation utilities
//!
//! Переиспользуемые валидаторы для проверки пользовательского ввода.
/// Проверяет, что строка не пустая (после 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()
}
#[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"));
}
}