use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use crate::types::{LogEntry};
use crate::playlists::PlaylistItem;
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Play,
Pause,
Stop,
Toggle,
Open(String),
Load(String),
Volume(f64),
VolumeUp,
VolumeDown,
Mute,
Unmute,
Seek(f64),
SeekTo(f64),
Forward(f64),
Backward(f64),
SeekForward, SeekBackward,
Speed(f64),
SpeedUp,
SpeedDown,
SpeedReset,
ResetSpeed,
Fullscreen,
Windowed,
ToggleFullscreen,
Loop,
ToggleLoop,
Quit,
Help,
ShowHelp, HideHelp,
Connect(String, Option<u16>),
Disconnect,
TestConnection,
Config,
Save,
Reload,
ShowConfig, HideConfig, ConfigNext, ConfigPrev, ConfigEdit, ConfigReset, ConfigExport, ConfigImport,
YoutubeClearCache, YoutubeShowCache, YoutubeDownload(String),
PlaylistPlay { playlist_id: String, index: usize },
PlaylistAdd { name: String },
PlaylistDelete { playlist_id: String },
PlaylistItemAdd { playlist_id: String, item: PlaylistItem },
PlaylistItemDelete { playlist_id: String, item_id: String },
PlaylistReload, PlaylistNowPlaying { playlist_id: String, item_id: String },
PlaylistClearNowPlaying,
UpdateState,
Log(LogEntry),
ExecuteCommand(String), }
impl Command {
#[allow(dead_code)]
pub fn open_media_from_path(path: String) -> Self {
Command::Open(path)
}
}
#[derive(Debug, Clone)]
pub struct CommandSuggestion {
pub command: String,
pub description: String,
pub score: i64,
}
const COMMAND_DATA: &[(&str, &str)] = &[
("play", "Start playback"),
("pause", "Pause playback"),
("stop", "Stop playback"),
("toggle", "Toggle play/pause"),
("open", "Open media file or URL"),
("load", "Load media file or URL"),
("volume", "Set volume (0-100)"),
("vol", "Set volume (0-100)"),
("volumeup", "Increase volume"),
("volup", "Increase volume"),
("volumedown", "Decrease volume"),
("voldown", "Decrease volume"),
("mute", "Mute audio"),
("unmute", "Unmute audio"),
("seek", "Seek relative to current position"),
("seekto", "Seek to absolute position"),
("forward", "Seek forward"),
("backward", "Seek backward"),
("speed", "Set playback speed"),
("speedup", "Increase playback speed"),
("speeddown", "Decrease playback speed"),
("speedreset", "Reset playback speed to 1.0x"),
("fullscreen", "Enter fullscreen mode"),
("windowed", "Exit fullscreen mode"),
("togglefullscreen", "Toggle fullscreen mode"),
("loop", "Toggle loop mode"),
("toggleloop", "Toggle loop mode"),
("quit", "Quit application"),
("exit", "Exit application"),
("help", "Show help information"),
("connect", "Connect to VLC instance"),
("disconnect", "Disconnect from VLC"),
("test", "Test VLC connection"),
("config", "Show configuration"),
("save", "Save configuration"),
("reload", "Reload configuration"),
("export", "Export configuration to file"),
("import", "Import configuration from file"),
("reset", "Reset configuration to defaults"),
("ytcache", "Show YouTube cache statistics"),
("ytclear", "Clear YouTube cache"),
("ytdownload", "Download YouTube video"),
("youtube", "YouTube commands"),
("cache", "Cache management"),
("pladd", "Add a new playlist: pladd <name>"),
("pldel", "Delete a playlist: pldel <playlist_id>"),
("plplay", "Play a playlist from index: plplay <playlist_id> [index]"),
("plitemadd", "Add item to playlist: plitemadd <playlist_id> <url> [name]"),
("plitemdel", "Delete item from playlist: plitemdel <playlist_id> <item_id>"),
("plreload", "Reload playlists from disk"),
];
impl CommandSuggestion {
pub fn new(command: String, description: String, score: i64) -> Self {
Self {
command,
description,
score,
}
}
}
pub fn get_command_suggestions(input: &str) -> Vec<CommandSuggestion> {
if input.is_empty() {
return COMMAND_DATA.iter()
.map(|(cmd, desc)| CommandSuggestion::new(cmd.to_string(), desc.to_string(), 1000))
.collect();
}
let matcher = SkimMatcherV2::default();
let input_lower = input.to_lowercase();
let mut suggestions: Vec<CommandSuggestion> = Vec::new();
for (cmd, desc) in COMMAND_DATA.iter() {
let score = calculate_command_score(&input_lower, cmd, &matcher);
if score > 0 {
suggestions.push(CommandSuggestion::new(
cmd.to_string(),
desc.to_string(),
score,
));
}
}
for (cmd, desc) in COMMAND_DATA.iter() {
if let Some(aliases) = get_command_aliases(cmd) {
for alias in aliases {
let alias_score = calculate_command_score(&input_lower, alias, &matcher);
if alias_score > 0 {
suggestions.push(CommandSuggestion::new(
cmd.to_string(),
format!("{} (alias: {})", desc, alias),
alias_score - 100,
));
}
}
}
}
if suggestions.len() < 3 {
for (cmd, desc) in COMMAND_DATA.iter() {
if suggestions.iter().any(|s| s.command == *cmd) {
continue;
}
if fuzzy_char_match(&input_lower, cmd) {
suggestions.push(CommandSuggestion::new(
cmd.to_string(),
desc.to_string(),
100, ));
}
}
}
suggestions.sort_by(|a, b| b.score.cmp(&a.score));
suggestions.dedup_by(|a, b| a.command == b.command);
suggestions.truncate(10); suggestions
}
fn fuzzy_char_match(input: &str, command: &str) -> bool {
if input.is_empty() {
return true;
}
let input_chars: Vec<char> = input.chars().collect();
let command_chars: Vec<char> = command.chars().collect();
let mut input_idx = 0;
for &cmd_char in &command_chars {
if input_idx < input_chars.len() && cmd_char == input_chars[input_idx] {
input_idx += 1;
}
}
input_idx == input_chars.len()
}
fn calculate_command_score(input: &str, command: &str, matcher: &SkimMatcherV2) -> i64 {
if input == command {
return 10000;
}
if command.starts_with(input) {
return 5000 + (command.len() as i64 - input.len() as i64);
}
if command.contains(input) {
return 2000 + (command.len() as i64 - input.len() as i64);
}
let fuzzy_score = matcher.fuzzy_match(command, input).unwrap_or(0);
if input.len() <= 3 && fuzzy_score > 0 {
return fuzzy_score.max(50); }
fuzzy_score
}
fn get_command_aliases(command: &str) -> Option<Vec<&'static str>> {
match command {
"volume" => Some(vec!["vol", "sound"]),
"volumeup" => Some(vec!["volup", "louder", "increase"]),
"volumedown" => Some(vec!["voldown", "quieter", "decrease"]),
"speedup" => Some(vec!["faster", "accelerate"]),
"speeddown" => Some(vec!["slower", "decelerate"]),
"speedreset" => Some(vec!["normal", "reset"]),
"togglefullscreen" => Some(vec!["fs", "full"]),
"toggleloop" => Some(vec!["loop", "repeat"]),
"seekto" => Some(vec!["goto", "jump"]),
"test" => Some(vec!["testconnection", "ping"]),
"quit" => Some(vec!["q", "exit"]),
"exit" => Some(vec!["q", "quit"]),
_ => None,
}
}
pub fn parse_command(input: &str) -> Result<Command, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("Empty command".to_string());
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
let cmd_str = parts[0].to_lowercase();
match cmd_str.as_str() {
"play" => Ok(Command::Play),
"pause" => Ok(Command::Pause),
"stop" => Ok(Command::Stop),
"toggle" => Ok(Command::Toggle),
"open" | "load" => {
if parts.len() < 2 {
return Err(format!("{} requires a file path or URL", cmd_str));
}
let path = parts[1..].join(" ");
match cmd_str.as_str() {
"open" => Ok(Command::Open(path)),
"load" => Ok(Command::Load(path)),
_ => unreachable!(),
}
},
"volume" | "vol" => {
if parts.len() < 2 {
return Err("Volume requires a value (0-100)".to_string());
}
match parts[1].parse::<f64>() {
Ok(vol) if (0.0..=100.0).contains(&vol) => Ok(Command::Volume(vol)),
Ok(_) => Err("Volume must be between 0 and 100".to_string()),
Err(_) => Err("Volume must be a number".to_string()),
}
},
"volumeup" | "volup" => Ok(Command::VolumeUp),
"volumedown" | "voldown" => Ok(Command::VolumeDown),
"mute" => Ok(Command::Mute),
"unmute" => Ok(Command::Unmute),
"seek" => {
if parts.len() < 2 {
return Err("Seek requires a time value".to_string());
}
match parts[1].parse::<f64>() {
Ok(time) => Ok(Command::Seek(time)),
Err(_) => Err("Seek time must be a number".to_string()),
}
},
"seekto" | "goto" | "jump" => {
if parts.len() < 2 {
return Err("SeekTo requires a time value".to_string());
}
match parts[1].parse::<f64>() {
Ok(time) => Ok(Command::SeekTo(time)),
Err(_) => Err("SeekTo time must be a number".to_string()),
}
},
"forward" => {
let time = if parts.len() > 1 {
parts[1].parse::<f64>().unwrap_or(10.0)
} else {
10.0
};
Ok(Command::Forward(time))
},
"backward" => {
let time = if parts.len() > 1 {
parts[1].parse::<f64>().unwrap_or(10.0)
} else {
10.0
};
Ok(Command::Backward(time))
},
"speed" => {
if parts.len() < 2 {
return Err("Speed requires a value".to_string());
}
match parts[1].parse::<f64>() {
Ok(speed) if speed > 0.0 => Ok(Command::Speed(speed)),
Ok(_) => Err("Speed must be greater than 0".to_string()),
Err(_) => Err("Speed must be a number".to_string()),
}
},
"speedup" | "faster" => Ok(Command::SpeedUp),
"speeddown" | "slower" => Ok(Command::SpeedDown),
"speedreset" | "normal" | "reset" => Ok(Command::SpeedReset),
"resetspeed" => Ok(Command::ResetSpeed),
"fullscreen" => Ok(Command::Fullscreen),
"windowed" => Ok(Command::Windowed),
"togglefullscreen" | "fs" | "full" => Ok(Command::ToggleFullscreen),
"loop" | "toggleloop" => Ok(Command::ToggleLoop),
"quit" | "q" | "exit" => Ok(Command::Quit),
"help" => Ok(Command::Help),
"showhelp" => Ok(Command::ShowHelp),
"hidehelp" => Ok(Command::HideHelp),
"seekforward" => Ok(Command::SeekForward),
"seekbackward" => Ok(Command::SeekBackward),
"connect" => {
if parts.len() < 2 {
return Err("Connect requires a host".to_string());
}
let host = parts[1].to_string();
let port = if parts.len() > 2 {
match parts[2].parse::<u16>() {
Ok(p) => Some(p),
Err(_) => return Err("Port must be a valid number".to_string()),
}
} else {
None
};
Ok(Command::Connect(host, port))
},
"disconnect" => Ok(Command::Disconnect),
"test" | "testconnection" | "ping" => Ok(Command::TestConnection),
"config" => Ok(Command::ShowConfig),
"save" => Ok(Command::Save),
"reload" => Ok(Command::Reload),
"configreset" => Ok(Command::ConfigReset),
"export" => {
if parts.len() > 1 {
let path = parts[1..].join(" ");
Ok(Command::ExecuteCommand(format!("export_to {}", path)))
} else {
Ok(Command::ConfigExport)
}
},
"import" => {
if parts.len() > 1 {
let path = parts[1..].join(" ");
Ok(Command::ExecuteCommand(format!("import_from {}", path)))
} else {
Ok(Command::ConfigImport)
}
},
"ytcache" | "youtubecache" | "yt-cache" => Ok(Command::YoutubeShowCache),
"ytclear" | "youtubeclear" | "yt-clear" => Ok(Command::YoutubeClearCache),
"ytdownload" | "youtubedownload" | "yt-download" => {
if parts.len() < 2 {
return Err("YouTube download requires a URL".to_string());
}
let url = parts[1..].join(" ");
Ok(Command::YoutubeDownload(url))
},
"pladd" => {
if parts.len() < 2 { return Err("Usage: pladd <name>".into()); }
Ok(Command::PlaylistAdd { name: parts[1..].join(" ") })
}
"pldel" => {
if parts.len() < 2 { return Err("Usage: pldel <playlist_id>".into()); }
Ok(Command::PlaylistDelete { playlist_id: parts[1].to_string() })
}
"plplay" => {
if parts.len() < 2 { return Err("Usage: plplay <playlist_id> [index]".into()); }
let idx = if parts.len() > 2 { parts[2].parse::<usize>().unwrap_or(0) } else { 0 };
Ok(Command::PlaylistPlay { playlist_id: parts[1].to_string(), index: idx })
}
"plitemadd" => {
use crate::playlists::PlaylistItem;
if parts.len() < 3 { return Err("Usage: plitemadd <playlist_id> <url> [name]".into()); }
let playlist_id = parts[1].to_string();
let url = parts[2].to_string();
let name = if parts.len() > 3 { parts[3..].join(" ") } else { url.clone() };
let item = PlaylistItem { name, id: String::new(), url, num_played: 0, duration: "".into(), time_added: 0 };
Ok(Command::PlaylistItemAdd { playlist_id, item })
}
"plitemdel" => {
if parts.len() < 3 { return Err("Usage: plitemdel <playlist_id> <item_id>".into()); }
Ok(Command::PlaylistItemDelete { playlist_id: parts[1].to_string(), item_id: parts[2].to_string() })
}
"plreload" => Ok(Command::PlaylistReload),
_ => Err(format!("Unknown command: {}", cmd_str)),
}
}
#[allow(dead_code)]
pub fn get_all_commands() -> Vec<(String, String)> {
COMMAND_DATA.iter()
.map(|(cmd, desc)| (cmd.to_string(), desc.to_string()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_commands() {
assert_eq!(parse_command("play").unwrap(), Command::Play);
assert_eq!(parse_command("pause").unwrap(), Command::Pause);
assert_eq!(parse_command("STOP").unwrap(), Command::Stop);
}
#[test]
fn test_parse_volume_commands() {
assert_eq!(parse_command("volume 50").unwrap(), Command::Volume(50.0));
assert_eq!(parse_command("vol 75").unwrap(), Command::Volume(75.0));
assert!(parse_command("volume 150").is_err());
assert!(parse_command("volume abc").is_err());
}
#[test]
fn test_parse_file_commands() {
assert_eq!(
parse_command("open /path/to/file.mp4").unwrap(),
Command::Open("/path/to/file.mp4".to_string())
);
assert_eq!(
parse_command("load https://example.com/video.mp4").unwrap(),
Command::Load("https://example.com/video.mp4".to_string())
);
}
#[test]
fn test_command_suggestions() {
let suggestions = get_command_suggestions("pl");
assert!(!suggestions.is_empty());
assert!(suggestions.iter().any(|s| s.command == "play"));
}
#[test]
fn test_quit_command_parsing() {
let quit_commands = vec!["quit", "q", "exit", "QUIT", "EXIT"];
for cmd in quit_commands {
let result = parse_command(cmd);
println!("Parsing '{}' -> {:?}", cmd, result);
assert!(result.is_ok(), "Should parse '{}' successfully", cmd);
assert_eq!(result.unwrap(), Command::Quit, "'{}' should parse to Command::Quit", cmd);
}
}
#[test]
fn test_single_suggestion_cases() {
let test_cases = vec![
("disconn", "disconnect"),
("confi", "config"),
("relo", "reload"),
];
for (input, expected) in test_cases {
let suggestions = get_command_suggestions(input);
println!("Input '{}' -> {} suggestions", input, suggestions.len());
for (i, s) in suggestions.iter().enumerate() {
println!(" {}: {} - {}", i, s.command, s.description);
}
assert!(!suggestions.is_empty(), "Should have suggestions for '{}'", input);
assert_eq!(suggestions[0].command, expected,
"First suggestion for '{}' should be '{}'", input, expected);
}
}
#[test]
fn test_fuzzy_matching_edge_cases() {
let suggestions = get_command_suggestions("pla");
assert!(!suggestions.is_empty(), "Should have suggestions for 'pla'");
assert!(suggestions.iter().any(|s| s.command == "play"), "Should suggest 'play' for 'pla'");
let suggestions = get_command_suggestions("pa");
assert!(suggestions.iter().any(|s| s.command == "pause"), "Should suggest 'pause' for 'pa'");
let suggestions = get_command_suggestions("vo");
assert!(suggestions.iter().any(|s| s.command == "volume"), "Should suggest 'volume' for 'vo'");
let suggestions = get_command_suggestions("p");
assert!(!suggestions.is_empty(), "Should have suggestions for 'p'");
let suggestions = get_command_suggestions("stp");
assert!(suggestions.iter().any(|s| s.command == "stop"), "Should suggest 'stop' for 'stp'");
}
}