use std::path::{Path, PathBuf}; use std::fs; use std::time::Duration; use std::process::{Child, Command}; use seqdiff::ratio; mod structs; pub use structs::*; use crate::{config, i18n, APP_DIR}; pub fn parse_commands() -> Result, String> { let mut commands: Vec = Vec::new(); let commands_path = APP_DIR.join(config::COMMANDS_PATH); let cmd_dirs = fs::read_dir(&commands_path) .map_err(|e| format!("Error reading commands directory {:?}: {}", commands_path, e))?; for entry in cmd_dirs.flatten() { let cmd_path = entry.path(); let toml_file = cmd_path.join("command.toml"); if !toml_file.exists() { continue; } let content = match fs::read_to_string(&toml_file) { Ok(c) => c, Err(e) => { warn!("Failed to read {}: {}", toml_file.display(), e); continue; } }; let file: JCommandsList = match toml::from_str(&content) { Ok(f) => f, Err(e) => { warn!("Failed to parse {}: {}", toml_file.display(), e); continue; } }; commands.push(JCommandsList { path: cmd_path, commands: file.commands, }); } if commands.is_empty() { Err("No commands found".into()) } else { info!("Loaded {} command pack(s)", commands.len()); Ok(commands) } } pub fn commands_hash(commands: &[JCommandsList]) -> String { use sha2::{Sha256, Digest}; let mut hasher = Sha256::new(); let lang = i18n::get_language(); hasher.update(lang.as_bytes()); hasher.update(b"|"); // collect all command ids and phrases for current language, sorted let mut all_data: Vec<(&str, _)> = commands.iter() .flat_map(|ac| ac.commands.iter().map(|c| (c.id.as_str(), c.get_phrases(&lang)))) .collect(); all_data.sort_by_key(|(id, _)| *id); for (id, phrases) in all_data { hasher.update(id.as_bytes()); for phrase in phrases.iter() { hasher.update(phrase.as_bytes()); } } format!("{:x}", hasher.finalize()) } pub fn fetch_command<'a>( phrase: &str, commands: &'a [JCommandsList], ) -> Option<(&'a PathBuf, &'a JCommand)> { let lang = i18n::get_language(); let phrase = phrase.trim().to_lowercase(); if phrase.is_empty() { return None; } let phrase_chars: Vec = phrase.chars().collect(); let phrase_words: Vec<&str> = phrase.split_whitespace().collect(); let mut result: Option<(&PathBuf, &JCommand)> = None; let mut best_score = config::CMD_RATIO_THRESHOLD; for cmd_list in commands { for cmd in &cmd_list.commands { let cmd_phrases = cmd.get_phrases(&lang); for cmd_phrase in cmd_phrases.iter() { let cmd_phrase_lower = cmd_phrase.trim().to_lowercase(); let cmd_phrase_chars: Vec = cmd_phrase_lower.chars().collect(); // character-level similarity let char_ratio = ratio(&phrase_chars, &cmd_phrase_chars); // word-level similarity let cmd_words: Vec<&str> = cmd_phrase_lower.split_whitespace().collect(); let word_score = word_overlap_score(&phrase_words, &cmd_words); // combined score let score = (char_ratio * 0.6) + (word_score * 0.4); // early exit on perfect match if score >= 99.0 { debug!("Perfect match: '{}' -> '{}'", phrase, cmd_phrase_lower); return Some((&cmd_list.path, cmd)); } if score > best_score { best_score = score; result = Some((&cmd_list.path, cmd)); } } } } if let Some((_, cmd)) = result { info!("Fuzzy match: '{}' -> cmd '{}' (score: {:.1}%)", phrase, cmd.id, best_score); } else { debug!("No match for '{}' (best: {:.1}%)", phrase, best_score); } result } fn word_overlap_score(input_words: &[&str], cmd_words: &[&str]) -> f64 { if input_words.is_empty() || cmd_words.is_empty() { return 0.0; } let mut matched = 0.0; // pre-compute cmd word chars to avoid repeated allocations let cmd_word_chars: Vec> = cmd_words .iter() .map(|w| w.chars().collect()) .collect(); for input_word in input_words { let input_chars: Vec = input_word.chars().collect(); let best_word_match = cmd_word_chars .iter() .map(|cw| ratio(&input_chars, cw)) .fold(0.0_f64, f64::max); if best_word_match > 70.0 { matched += best_word_match / 100.0; } } let max_words = input_words.len().max(cmd_words.len()) as f64; (matched / max_words) * 100.0 } pub fn execute_exe(exe: &str, args: &[String]) -> std::io::Result { Command::new(exe).args(args).spawn() } pub fn execute_cli(cmd: &str, args: &[String]) -> std::io::Result { debug!("Spawning: cmd /C {} {:?}", cmd, args); if cfg!(target_os = "windows") { Command::new("cmd").arg("/C").arg(cmd).args(args).spawn() } else { Command::new("sh").arg("-c").arg(cmd).args(args).spawn() } } pub fn execute_command(cmd_path: &Path, cmd_config: &JCommand) -> Result { match cmd_config.action.as_str() { "voice" => Ok(true), "ahk" => { let exe_path_absolute = Path::new(&cmd_config.exe_path); let exe_path_local = cmd_path.join(&cmd_config.exe_path); let exe_path = if exe_path_absolute.exists() { exe_path_absolute } else { exe_path_local.as_path() }; execute_exe(exe_path.to_str().unwrap(), &cmd_config.exe_args) .map(|_| true) .map_err(|e| format!("AHK process spawn error: {}", e)) } "cli" => { execute_cli(&cmd_config.cli_cmd, &cmd_config.cli_args) .map(|_| true) .map_err(|e| format!("CLI command error: {}", e)) } "terminate" => { std::thread::sleep(Duration::from_secs(2)); std::process::exit(0); } "stop_chaining" => Ok(false), _ => Err(format!("Unknown command type: {}", cmd_config.action)), } } pub fn list_paths(commands: &[JCommandsList]) -> Vec<&Path> { commands.iter().map(|x| x.path.as_path()).collect() }