1use std::path::PathBuf;
8use std::str::FromStr;
9use std::time::Duration;
10
11use thiserror::Error;
12
13
14#[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#[derive( Debug, Clone, PartialEq )]
33pub enum Command {
34 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 Goto { path: PathBuf },
46 Search { term: String },
47 Home,
48
49 Play,
51 Pause,
52 Stop,
53 Next,
54 Prev,
55 Seek { position: Duration },
56
57 Vis,
59 Volume { level: Option<u32> },
60 Help,
61 Quit,
62}
63
64
65#[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 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 "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 "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 "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 "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 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
199fn 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
221pub 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}