// SPDX-License-Identifier: Apache-2.0 //! Slash-command public API: types, parser, and dispatcher. //! Execution helpers live in `command_exec` (≤200 LOC split). //! `process_text` in serve.rs calls `parse_command` BEFORE `handle_step`. use std::sync::Arc; use crate::{ chat_log::ChatLog, command_exec as exec, contacts::Contacts, topics::Topics, }; /// Recognised slash-commands. `None` = not a command → fall through to FSM. pub enum Command<'a> { Whois(&'a str), Find(&'a str), Topics, Contacts, Help, SyncGoogle, SyncApple, } /// Shared store references passed to `execute_command`. pub struct CommandStores<'a> { pub chat_log: &'a Arc, pub contacts: &'a Arc, pub topics: &'a Arc, } const HELP_TEXT: &str = "Доступные команды:\n\ /whois <имя> — найти контакт\n\ /find <текст> — поиск по переписке\n\ /topics — список тем\n\ /contacts — список контактов\n\ /sync-google — синхронизировать контакты Google (нужен GOOGLE_OAUTH_ACCESS_TOKEN)\n\ /sync-apple — синхронизировать контакты Apple (нужны APPLE_ID / APPLE_APP_PASSWORD / APPLE_CARDDAV_URL)\n\ /help — это сообщение"; /// Parse a raw user text into a Command, or None if it is not a slash-command. pub fn parse_command(text: &str) -> Option> { let t = text.trim(); if !t.starts_with('/') { return None; } let rest = &t[1..]; // drop leading '/' if rest.eq_ignore_ascii_case("help") { return Some(Command::Help); } if rest.eq_ignore_ascii_case("topics") { return Some(Command::Topics); } if rest.eq_ignore_ascii_case("contacts") { return Some(Command::Contacts); } let lower = rest.to_lowercase(); if lower.starts_with("whois") { return Some(Command::Whois(rest[5..].trim())); } if lower.starts_with("find") { return Some(Command::Find(rest[4..].trim())); } if lower.eq("sync-google") { return Some(Command::SyncGoogle); } if lower.eq("sync-apple") { return Some(Command::SyncApple); } None } /// Execute a parsed command. Returns a human-readable response string. /// All errors become human-readable messages (Russian, English fallback). pub async fn execute_command( cmd: Command<'_>, chat_id: i64, stores: &CommandStores<'_>, ) -> String { match cmd { Command::Help => HELP_TEXT.to_string(), Command::Topics => exec::exec_topics(chat_id, stores.topics).await, Command::Contacts => exec::exec_contacts(stores.contacts).await, Command::Whois(name) => exec::exec_whois(name, stores.contacts).await, Command::Find(query) => exec::exec_find(query, chat_id, stores.chat_log).await, Command::SyncGoogle => exec::exec_sync_google(stores.contacts).await, Command::SyncApple => exec::exec_sync_apple(stores.contacts).await, } } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; fn make_stores<'a>( cl: &'a Arc, co: &'a Arc, to: &'a Arc, ) -> CommandStores<'a> { CommandStores { chat_log: cl, contacts: co, topics: to } } #[test] fn parse_help_no_args() { assert!(matches!(parse_command("/help"), Some(Command::Help))); } #[test] fn parse_whois_with_name() { let cmd = parse_command("/whois Denis"); assert!(matches!(cmd, Some(Command::Whois("Denis")))); } #[test] fn parse_non_command_returns_none() { assert!(parse_command("hello there").is_none()); } #[tokio::test] async fn execute_help_returns_help_text() { let cl = Arc::new(ChatLog::from_memory().unwrap()); let co = Arc::new(Contacts::from_memory().unwrap()); let to = Arc::new(Topics::from_memory().unwrap()); let stores = make_stores(&cl, &co, &to); let resp = execute_command(Command::Help, 1, &stores).await; assert!(resp.contains("/whois")); assert!(resp.contains("/find")); } #[tokio::test] async fn execute_topics_lists_added() { let cl = Arc::new(ChatLog::from_memory().unwrap()); let co = Arc::new(Contacts::from_memory().unwrap()); let to = Arc::new(Topics::from_memory().unwrap()); to.add_topic(42, "rust-lang", "Rust Language", "content").await.unwrap(); let stores = make_stores(&cl, &co, &to); let resp = execute_command(Command::Topics, 42, &stores).await; assert!(resp.contains("Rust Language")); } #[tokio::test] async fn execute_find_returns_matches() { let cl = Arc::new(ChatLog::from_memory().unwrap()); let co = Arc::new(Contacts::from_memory().unwrap()); let to = Arc::new(Topics::from_memory().unwrap()); cl.log_user(99, "unique_search_word here").await.unwrap(); let stores = make_stores(&cl, &co, &to); let resp = execute_command(Command::Find("unique_search_word"), 99, &stores).await; assert!(resp.contains("unique_search_word")); } #[tokio::test] async fn execute_contacts_empty_handled() { let cl = Arc::new(ChatLog::from_memory().unwrap()); let co = Arc::new(Contacts::from_memory().unwrap()); let to = Arc::new(Topics::from_memory().unwrap()); let stores = make_stores(&cl, &co, &to); let resp = execute_command(Command::Contacts, 1, &stores).await; assert!(resp.contains("пусты") || resp.contains("контакт")); } #[test] fn parse_sync_google() { assert!(matches!(parse_command("/sync-google"), Some(Command::SyncGoogle))); } #[test] fn parse_sync_apple() { assert!(matches!(parse_command("/sync-apple"), Some(Command::SyncApple))); } #[tokio::test] async fn help_includes_sync_commands() { let cl = Arc::new(ChatLog::from_memory().unwrap()); let co = Arc::new(Contacts::from_memory().unwrap()); let to = Arc::new(Topics::from_memory().unwrap()); let stores = make_stores(&cl, &co, &to); let resp = execute_command(Command::Help, 1, &stores).await; assert!(resp.contains("/sync-google")); assert!(resp.contains("/sync-apple")); } }