oxidio_core/
command.rs

1//! Slash command parsing and execution.
2//!
3//! Provides the command infrastructure for the TUI slash commands.
4//! Commands are parsed from user input and can be executed against
5//! the player and playlist.
6
7use std::path::PathBuf;
8use std::str::FromStr;
9use std::time::Duration;
10
11use thiserror::Error;
12
13
14/// Errors that can occur during command parsing or execution.
15#[derive( Debug, Error )]
16pub enum CommandError {
17    #[error( "Unknown command: {0}" )]
18    Unknown( String ),
19
20    #[error( "Invalid argument: {0}" )]
21    InvalidArgument( String ),
22
23    #[error( "Missing argument: {0}" )]
24    MissingArgument( String ),
25
26    #[error( "Execution failed: {0}" )]
27    ExecutionFailed( String ),
28}
29
30
31/// Parsed slash command.
32#[derive( Debug, Clone, PartialEq )]
33pub enum Command {
34    // Playlist commands
35    Add { path: PathBuf },
36    Remove,
37    Clear,
38    Dedup,
39    Save { name: String },
40    Load { name: String },
41    Shuffle,
42    Repeat { mode: Option<RepeatModeArg> },
43
44    // Navigation commands
45    Goto { path: PathBuf },
46    Search { term: String },
47    Home,
48
49    // Playback commands
50    Play,
51    Pause,
52    Stop,
53    Next,
54    Prev,
55    Seek { position: Duration },
56
57    // UI commands
58    Vis,
59    Volume { level: Option<u32> },
60    Help,
61    Quit,
62}
63
64
65/// Repeat mode argument for parsing.
66#[derive( Debug, Clone, Copy, PartialEq, Eq )]
67pub enum RepeatModeArg {
68    Off,
69    One,
70    All,
71}
72
73
74impl FromStr for RepeatModeArg {
75    type Err = CommandError;
76
77
78    fn from_str( s: &str ) -> Result<Self, Self::Err> {
79        match s.to_lowercase().as_str() {
80            "off" | "0" => Ok( RepeatModeArg::Off ),
81            "one" | "1" => Ok( RepeatModeArg::One ),
82            "all" | "2" => Ok( RepeatModeArg::All ),
83            _ => Err( CommandError::InvalidArgument(
84                format!( "Invalid repeat mode: '{}'. Use 'off', 'one', or 'all'", s )
85            )),
86        }
87    }
88}
89
90
91impl Command {
92    /// Parses a command string (without the leading `/`).
93    ///
94    /// @param input - The command string to parse
95    ///
96    /// @returns The parsed command or an error
97    pub fn parse( input: &str ) -> Result<Self, CommandError> {
98        let input = input.trim();
99        let mut parts = input.splitn( 2, ' ' );
100        let cmd = parts.next().unwrap_or( "" ).to_lowercase();
101        let args = parts.next().map( |s| s.trim() );
102
103        match cmd.as_str() {
104            // Playlist commands
105            "add" | "a" => {
106                let path = args
107                    .ok_or_else( || CommandError::MissingArgument( "path".into() ) )?;
108                Ok( Command::Add { path: PathBuf::from( path ) } )
109            }
110            "remove" | "rm" | "del" => Ok( Command::Remove ),
111            "clear" | "cl" => Ok( Command::Clear ),
112            "dedup" | "dedupe" | "unique" => Ok( Command::Dedup ),
113            "save" => {
114                let name = args
115                    .ok_or_else( || CommandError::MissingArgument( "playlist name".into() ) )?;
116                Ok( Command::Save { name: name.to_string() } )
117            }
118            "load" => {
119                let name = args
120                    .ok_or_else( || CommandError::MissingArgument( "playlist name".into() ) )?;
121                Ok( Command::Load { name: name.to_string() } )
122            }
123            "shuffle" | "sh" => Ok( Command::Shuffle ),
124            "repeat" | "rep" => {
125                let mode = args.map( |s| s.parse() ).transpose()?;
126                Ok( Command::Repeat { mode } )
127            }
128
129            // Navigation commands
130            "goto" | "go" | "cd" => {
131                let path = args
132                    .ok_or_else( || CommandError::MissingArgument( "path".into() ) )?;
133                Ok( Command::Goto { path: PathBuf::from( path ) } )
134            }
135            "search" | "find" | "?" => {
136                let term = args
137                    .ok_or_else( || CommandError::MissingArgument( "search term".into() ) )?;
138                Ok( Command::Search { term: term.to_string() } )
139            }
140            "home" | "~" => Ok( Command::Home ),
141
142            // Playback commands
143            "play" | "p" => Ok( Command::Play ),
144            "pause" | "pa" => Ok( Command::Pause ),
145            "stop" | "st" => Ok( Command::Stop ),
146            "next" | "n" => Ok( Command::Next ),
147            "prev" | "previous" | "pr" => Ok( Command::Prev ),
148            "seek" | "sk" => {
149                let time_str = args
150                    .ok_or_else( || CommandError::MissingArgument( "time position".into() ) )?;
151                let position = parse_time( time_str )?;
152                Ok( Command::Seek { position } )
153            }
154
155            // UI commands
156            "vis" | "visualizer" => Ok( Command::Vis ),
157            "vol" | "volume" => {
158                let level = args.and_then( |s| s.parse().ok() );
159                Ok( Command::Volume { level } )
160            }
161            "help" | "h" => Ok( Command::Help ),
162            "quit" | "q" | "exit" => Ok( Command::Quit ),
163
164            "" => Err( CommandError::Unknown( "empty command".into() ) ),
165            other => Err( CommandError::Unknown( other.to_string() ) ),
166        }
167    }
168
169
170    /// Returns a brief description of the command for help text.
171    pub fn description( &self ) -> &'static str {
172        match self {
173            Command::Add { .. } => "Add file/folder to playlist",
174            Command::Remove => "Remove selected track",
175            Command::Clear => "Clear playlist",
176            Command::Dedup => "Remove duplicate tracks",
177            Command::Save { .. } => "Save playlist",
178            Command::Load { .. } => "Load playlist",
179            Command::Shuffle => "Toggle shuffle",
180            Command::Repeat { .. } => "Set repeat mode",
181            Command::Goto { .. } => "Navigate to path",
182            Command::Search { .. } => "Search/filter",
183            Command::Home => "Go to home directory",
184            Command::Play => "Play selected track",
185            Command::Pause => "Pause playback",
186            Command::Stop => "Stop playback",
187            Command::Next => "Next track",
188            Command::Prev => "Previous track",
189            Command::Seek { .. } => "Seek to position",
190            Command::Vis => "Toggle visualizer",
191            Command::Volume { .. } => "Set volume (0-100)",
192            Command::Help => "Show help",
193            Command::Quit => "Quit application",
194        }
195    }
196}
197
198
199/// Parses a time string like "1:30" or "90" into a Duration.
200///
201/// @param s - Time string in format "MM:SS", "M:SS", or just seconds
202///
203/// @returns Duration or error
204fn parse_time( s: &str ) -> Result<Duration, CommandError> {
205    let s = s.trim();
206
207    if let Some(( min, sec )) = s.split_once( ':' ) {
208        let minutes: u64 = min.parse()
209            .map_err( |_| CommandError::InvalidArgument( format!( "Invalid minutes: {}", min ) ) )?;
210        let seconds: u64 = sec.parse()
211            .map_err( |_| CommandError::InvalidArgument( format!( "Invalid seconds: {}", sec ) ) )?;
212        Ok( Duration::from_secs( minutes * 60 + seconds ) )
213    } else {
214        let seconds: u64 = s.parse()
215            .map_err( |_| CommandError::InvalidArgument( format!( "Invalid time: {}", s ) ) )?;
216        Ok( Duration::from_secs( seconds ) )
217    }
218}
219
220
221/// Returns help text listing all available commands.
222pub fn help_text() -> &'static str {
223    r#"Playlist Commands:
224  /add <path>     Add file/folder to playlist
225  /remove         Remove selected track
226  /clear          Clear playlist
227  /dedup          Remove duplicate tracks
228  /shuffle        Toggle shuffle mode
229  /repeat [mode]  Set repeat (off/one/all)
230
231Navigation Commands:
232  /goto <path>    Navigate browser to path
233  /search <term>  Filter current view
234  /home           Go to home directory
235
236Playback Commands:
237  /play           Play selected track
238  /pause          Pause playback
239  /stop           Stop playback
240  /next           Next track
241  /prev           Previous track
242  /seek <time>    Seek to position (e.g., 1:30)
243
244Other Commands:
245  /vis            Toggle visualizer      [v]
246  /vol [0-100]    Set volume             [+/-]
247  /help           Show this help         [?]
248  /quit           Exit oxidio            [q]"#
249}
250
251
252#[cfg( test )]
253mod tests {
254    use super::*;
255
256
257    #[test]
258    fn test_parse_add() {
259        let cmd = Command::parse( "add /path/to/file.mp3" ).unwrap();
260        assert_eq!( cmd, Command::Add { path: PathBuf::from( "/path/to/file.mp3" ) } );
261    }
262
263
264    #[test]
265    fn test_parse_add_alias() {
266        let cmd = Command::parse( "a /music" ).unwrap();
267        assert_eq!( cmd, Command::Add { path: PathBuf::from( "/music" ) } );
268    }
269
270
271    #[test]
272    fn test_parse_seek() {
273        let cmd = Command::parse( "seek 1:30" ).unwrap();
274        assert_eq!( cmd, Command::Seek { position: Duration::from_secs( 90 ) } );
275    }
276
277
278    #[test]
279    fn test_parse_seek_seconds() {
280        let cmd = Command::parse( "seek 45" ).unwrap();
281        assert_eq!( cmd, Command::Seek { position: Duration::from_secs( 45 ) } );
282    }
283
284
285    #[test]
286    fn test_parse_repeat_with_mode() {
287        let cmd = Command::parse( "repeat all" ).unwrap();
288        assert_eq!( cmd, Command::Repeat { mode: Some( RepeatModeArg::All ) } );
289    }
290
291
292    #[test]
293    fn test_parse_repeat_toggle() {
294        let cmd = Command::parse( "repeat" ).unwrap();
295        assert_eq!( cmd, Command::Repeat { mode: None } );
296    }
297
298
299    #[test]
300    fn test_parse_unknown() {
301        let result = Command::parse( "foobar" );
302        assert!( matches!( result, Err( CommandError::Unknown( _ ) ) ) );
303    }
304
305
306    #[test]
307    fn test_parse_missing_arg() {
308        let result = Command::parse( "add" );
309        assert!( matches!( result, Err( CommandError::MissingArgument( _ ) ) ) );
310    }
311}