1pub mod args;
6pub mod commands;
7
8use std::io;
9
10use clap::{CommandFactory, Parser, Subcommand};
11use clap_complete::{Shell, generate};
12
13use crate::constants::DEFAULT_LIMIT;
14
15pub use args::*;
17pub use clap_complete::Shell as CompletionShell;
18
19pub fn print_completions(shell: Shell) {
21 let mut cmd = Cli::command();
22 generate(shell, &mut cmd, "spotify-cli", &mut io::stdout());
23}
24
25#[derive(Parser)]
26#[command(name = "spotify-cli", version)]
27#[command(about = "Command line interface for Spotify")]
28pub struct Cli {
29 #[command(subcommand)]
30 pub command: Command,
31
32 #[arg(long, short = 'j', global = true)]
34 pub json: bool,
35
36 #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
38 pub verbose: u8,
39
40 #[arg(long, global = true, default_value = "pretty")]
42 pub log_format: String,
43}
44
45#[derive(Subcommand)]
46pub enum Command {
47 Auth {
49 #[command(subcommand)]
50 command: AuthCommand,
51 },
52 #[command(alias = "p")]
54 Player {
55 #[command(subcommand)]
56 command: PlayerCommand,
57 },
58 Pin {
60 #[command(subcommand)]
61 command: PinCommand,
62 },
63 #[command(alias = "s")]
65 Search {
66 #[arg(default_value = "")]
68 query: String,
69 #[arg(long = "type", short = 'T')]
72 types: Vec<String>,
73 #[arg(long, short = 'l', default_value_t = DEFAULT_LIMIT)]
75 limit: u8,
76 #[arg(long)]
78 pins_only: bool,
79 #[arg(long, short = 'e')]
81 exact: bool,
82 #[arg(long, short = 'a')]
84 artist: Option<String>,
85 #[arg(long, short = 'A')]
87 album: Option<String>,
88 #[arg(long, short = 't')]
90 track: Option<String>,
91 #[arg(long, short = 'y')]
93 year: Option<String>,
94 #[arg(long, short = 'g')]
96 genre: Option<String>,
97 #[arg(long)]
99 isrc: Option<String>,
100 #[arg(long)]
102 upc: Option<String>,
103 #[arg(long)]
105 new: bool,
106 #[arg(long)]
108 hipster: bool,
109 #[arg(long, short = 'p')]
111 play: bool,
112 #[arg(long, short = 's')]
114 sort: bool,
115 },
116 #[command(alias = "pl")]
118 Playlist {
119 #[command(subcommand)]
120 command: PlaylistCommand,
121 },
122 #[command(alias = "lib")]
124 Library {
125 #[command(subcommand)]
126 command: LibraryCommand,
127 },
128 #[command(alias = "i")]
130 Info {
131 #[command(subcommand)]
132 command: InfoCommand,
133 },
134 User {
136 #[command(subcommand)]
137 command: UserCommand,
138 },
139 Show {
141 #[command(subcommand)]
142 command: ShowCommand,
143 },
144 Episode {
146 #[command(subcommand)]
147 command: EpisodeCommand,
148 },
149 Audiobook {
151 #[command(subcommand)]
152 command: AudiobookCommand,
153 },
154 Album {
156 #[command(subcommand)]
157 command: AlbumCommand,
158 },
159 Chapter {
161 #[command(subcommand)]
162 command: ChapterCommand,
163 },
164 Category {
166 #[command(subcommand)]
167 command: CategoryCommand,
168 },
169 Follow {
171 #[command(subcommand)]
172 command: FollowCommand,
173 },
174 Markets,
176 #[cfg(unix)]
178 Daemon {
179 #[command(subcommand)]
180 command: DaemonCommand,
181 },
182 Completions {
184 #[arg(value_enum)]
186 shell: Shell,
187 },
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn parse_auth_login() {
196 let cli = Cli::try_parse_from(["spotify-cli", "auth", "login"]).unwrap();
197 match cli.command {
198 Command::Auth {
199 command: AuthCommand::Login { force },
200 } => {
201 assert!(!force);
202 }
203 _ => panic!("Expected Auth Login command"),
204 }
205 }
206
207 #[test]
208 fn parse_auth_login_force() {
209 let cli = Cli::try_parse_from(["spotify-cli", "auth", "login", "-f"]).unwrap();
210 match cli.command {
211 Command::Auth {
212 command: AuthCommand::Login { force },
213 } => {
214 assert!(force);
215 }
216 _ => panic!("Expected Auth Login command"),
217 }
218 }
219
220 #[test]
221 fn parse_player_next() {
222 let cli = Cli::try_parse_from(["spotify-cli", "player", "next"]).unwrap();
223 match cli.command {
224 Command::Player {
225 command: PlayerCommand::Next,
226 } => {}
227 _ => panic!("Expected Player Next command"),
228 }
229 }
230
231 #[test]
232 fn parse_player_alias_p() {
233 let cli = Cli::try_parse_from(["spotify-cli", "p", "next"]).unwrap();
234 match cli.command {
235 Command::Player {
236 command: PlayerCommand::Next,
237 } => {}
238 _ => panic!("Expected Player Next command via alias"),
239 }
240 }
241
242 #[test]
243 fn parse_player_volume() {
244 let cli = Cli::try_parse_from(["spotify-cli", "player", "volume", "50"]).unwrap();
245 match cli.command {
246 Command::Player {
247 command: PlayerCommand::Volume { percent },
248 } => {
249 assert_eq!(percent, 50);
250 }
251 _ => panic!("Expected Player Volume command"),
252 }
253 }
254
255 #[test]
256 fn parse_player_volume_max() {
257 let cli = Cli::try_parse_from(["spotify-cli", "player", "volume", "100"]).unwrap();
258 match cli.command {
259 Command::Player {
260 command: PlayerCommand::Volume { percent },
261 } => {
262 assert_eq!(percent, 100);
263 }
264 _ => panic!("Expected Player Volume command"),
265 }
266 }
267
268 #[test]
269 fn parse_player_volume_invalid() {
270 let result = Cli::try_parse_from(["spotify-cli", "player", "volume", "101"]);
271 assert!(result.is_err());
272 }
273
274 #[test]
275 fn parse_search_default() {
276 let cli = Cli::try_parse_from(["spotify-cli", "search", "test query"]).unwrap();
277 match cli.command {
278 Command::Search {
279 query,
280 limit,
281 pins_only,
282 exact,
283 ..
284 } => {
285 assert_eq!(query, "test query");
286 assert_eq!(limit, 20);
287 assert!(!pins_only);
288 assert!(!exact);
289 }
290 _ => panic!("Expected Search command"),
291 }
292 }
293
294 #[test]
295 fn parse_search_with_options() {
296 let cli = Cli::try_parse_from([
297 "spotify-cli",
298 "search",
299 "query",
300 "--type",
301 "track",
302 "--limit",
303 "10",
304 "--pins-only",
305 "--exact",
306 ])
307 .unwrap();
308 match cli.command {
309 Command::Search {
310 query,
311 types,
312 limit,
313 pins_only,
314 exact,
315 ..
316 } => {
317 assert_eq!(query, "query");
318 assert_eq!(types, vec!["track"]);
319 assert_eq!(limit, 10);
320 assert!(pins_only);
321 assert!(exact);
322 }
323 _ => panic!("Expected Search command"),
324 }
325 }
326
327 #[test]
328 fn parse_search_alias_s() {
329 let cli = Cli::try_parse_from(["spotify-cli", "s", "query"]).unwrap();
330 match cli.command {
331 Command::Search { query, .. } => {
332 assert_eq!(query, "query");
333 }
334 _ => panic!("Expected Search command via alias"),
335 }
336 }
337
338 #[test]
339 fn parse_json_flag() {
340 let cli = Cli::try_parse_from(["spotify-cli", "-j", "markets"]).unwrap();
341 assert!(cli.json);
342 }
343
344 #[test]
345 fn parse_verbose_flag() {
346 let cli = Cli::try_parse_from(["spotify-cli", "-v", "markets"]).unwrap();
347 assert_eq!(cli.verbose, 1);
348 }
349
350 #[test]
351 fn parse_verbose_multiple() {
352 let cli = Cli::try_parse_from(["spotify-cli", "-vvv", "markets"]).unwrap();
353 assert_eq!(cli.verbose, 3);
354 }
355
356 #[test]
357 fn parse_log_format() {
358 let cli = Cli::try_parse_from(["spotify-cli", "--log-format", "json", "markets"]).unwrap();
359 assert_eq!(cli.log_format, "json");
360 }
361
362 #[test]
363 fn parse_pin_add() {
364 let cli = Cli::try_parse_from([
365 "spotify-cli",
366 "pin",
367 "add",
368 "track",
369 "spotify:track:123",
370 "my alias",
371 ])
372 .unwrap();
373 match cli.command {
374 Command::Pin {
375 command:
376 PinCommand::Add {
377 resource_type,
378 url_or_id,
379 alias,
380 tags,
381 },
382 } => {
383 assert_eq!(resource_type, "track");
384 assert_eq!(url_or_id, "spotify:track:123");
385 assert_eq!(alias, "my alias");
386 assert!(tags.is_none());
387 }
388 _ => panic!("Expected Pin Add command"),
389 }
390 }
391
392 #[test]
393 fn parse_pin_add_with_tags() {
394 let cli = Cli::try_parse_from([
395 "spotify-cli",
396 "pin",
397 "add",
398 "playlist",
399 "123",
400 "alias",
401 "-t",
402 "tag1,tag2",
403 ])
404 .unwrap();
405 match cli.command {
406 Command::Pin {
407 command: PinCommand::Add { tags, .. },
408 } => {
409 assert_eq!(tags, Some("tag1,tag2".to_string()));
410 }
411 _ => panic!("Expected Pin Add command"),
412 }
413 }
414
415 #[test]
416 fn parse_playlist_list() {
417 let cli = Cli::try_parse_from(["spotify-cli", "playlist", "list"]).unwrap();
418 match cli.command {
419 Command::Playlist {
420 command: PlaylistCommand::List { limit, offset },
421 } => {
422 assert_eq!(limit, 20);
423 assert_eq!(offset, 0);
424 }
425 _ => panic!("Expected Playlist List command"),
426 }
427 }
428
429 #[test]
430 fn parse_library_alias() {
431 let cli = Cli::try_parse_from(["spotify-cli", "lib", "list"]).unwrap();
432 match cli.command {
433 Command::Library {
434 command: LibraryCommand::List { .. },
435 } => {}
436 _ => panic!("Expected Library List command via alias"),
437 }
438 }
439
440 #[test]
441 fn parse_info_alias() {
442 let cli = Cli::try_parse_from(["spotify-cli", "i", "track"]).unwrap();
443 match cli.command {
444 Command::Info {
445 command: InfoCommand::Track { .. },
446 } => {}
447 _ => panic!("Expected Info Track command via alias"),
448 }
449 }
450
451 #[test]
452 fn parse_markets() {
453 let cli = Cli::try_parse_from(["spotify-cli", "markets"]).unwrap();
454 match cli.command {
455 Command::Markets => {}
456 _ => panic!("Expected Markets command"),
457 }
458 }
459
460 #[test]
461 fn parse_player_repeat() {
462 let cli = Cli::try_parse_from(["spotify-cli", "player", "repeat", "track"]).unwrap();
463 match cli.command {
464 Command::Player {
465 command: PlayerCommand::Repeat { mode },
466 } => {
467 assert_eq!(mode, "track");
468 }
469 _ => panic!("Expected Player Repeat command"),
470 }
471 }
472
473 #[test]
474 fn parse_player_shuffle() {
475 let cli = Cli::try_parse_from(["spotify-cli", "player", "shuffle", "on"]).unwrap();
476 match cli.command {
477 Command::Player {
478 command: PlayerCommand::Shuffle { state },
479 } => {
480 assert_eq!(state, "on");
481 }
482 _ => panic!("Expected Player Shuffle command"),
483 }
484 }
485
486 #[test]
487 fn parse_player_seek() {
488 let cli = Cli::try_parse_from(["spotify-cli", "player", "seek", "1:30"]).unwrap();
489 match cli.command {
490 Command::Player {
491 command: PlayerCommand::Seek { position },
492 } => {
493 assert_eq!(position, "1:30");
494 }
495 _ => panic!("Expected Player Seek command"),
496 }
497 }
498
499 #[test]
500 fn parse_user_top() {
501 let cli =
502 Cli::try_parse_from(["spotify-cli", "user", "top", "tracks", "-r", "short"]).unwrap();
503 match cli.command {
504 Command::User {
505 command:
506 UserCommand::Top {
507 item_type,
508 range,
509 limit,
510 },
511 } => {
512 assert_eq!(item_type, "tracks");
513 assert_eq!(range, "short");
514 assert_eq!(limit, 20);
515 }
516 _ => panic!("Expected User Top command"),
517 }
518 }
519
520 #[test]
521 fn parse_user_top_default_range() {
522 let cli = Cli::try_parse_from(["spotify-cli", "user", "top", "artists"]).unwrap();
523 match cli.command {
524 Command::User {
525 command:
526 UserCommand::Top {
527 item_type,
528 range,
529 limit,
530 },
531 } => {
532 assert_eq!(item_type, "artists");
533 assert_eq!(range, "medium");
534 assert_eq!(limit, 20);
535 }
536 _ => panic!("Expected User Top command"),
537 }
538 }
539
540 #[test]
541 fn parse_follow_artist() {
542 let cli = Cli::try_parse_from(["spotify-cli", "follow", "artist", "123"]).unwrap();
543 match cli.command {
544 Command::Follow {
545 command: FollowCommand::Artist { ids, dry_run },
546 } => {
547 assert_eq!(ids, vec!["123"]);
548 assert!(!dry_run);
549 }
550 _ => panic!("Expected Follow Artist command"),
551 }
552 }
553}