spotify_cli/cli/
mod.rs

1//! CLI argument definitions and parsing.
2//!
3//! This module defines the command-line interface using clap.
4
5pub 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
15// Re-export all command types for convenience
16pub use args::*;
17pub use clap_complete::Shell as CompletionShell;
18
19/// Generate shell completion script to stdout
20pub 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    /// Output JSON response (silent if not specified)
33    #[arg(long, short = 'j', global = true)]
34    pub json: bool,
35
36    /// Enable verbose logging (use -vv for debug, -vvv for trace)
37    #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
38    pub verbose: u8,
39
40    /// Log output format (pretty or json)
41    #[arg(long, global = true, default_value = "pretty")]
42    pub log_format: String,
43}
44
45#[derive(Subcommand)]
46pub enum Command {
47    /// Authentication commands
48    Auth {
49        #[command(subcommand)]
50        command: AuthCommand,
51    },
52    /// Player controls (alias: p)
53    #[command(alias = "p")]
54    Player {
55        #[command(subcommand)]
56        command: PlayerCommand,
57    },
58    /// Manage pinned resources
59    Pin {
60        #[command(subcommand)]
61        command: PinCommand,
62    },
63    /// Search Spotify and pinned resources (alias: s)
64    #[command(alias = "s")]
65    Search {
66        /// Search query (can be empty if using filters)
67        #[arg(default_value = "")]
68        query: String,
69        /// Filter by type(s): track, artist, album, playlist, show, episode, audiobook
70        /// Can specify multiple: --type track --type album
71        #[arg(long = "type", short = 'T')]
72        types: Vec<String>,
73        /// Results per type (default 20, max 50)
74        #[arg(long, short = 'l', default_value_t = DEFAULT_LIMIT)]
75        limit: u8,
76        /// Only search pinned resources (skip Spotify API)
77        #[arg(long)]
78        pins_only: bool,
79        /// Only show results where name contains the query
80        #[arg(long, short = 'e')]
81        exact: bool,
82        /// Filter by artist name
83        #[arg(long, short = 'a')]
84        artist: Option<String>,
85        /// Filter by album name
86        #[arg(long, short = 'A')]
87        album: Option<String>,
88        /// Filter by track name
89        #[arg(long, short = 't')]
90        track: Option<String>,
91        /// Filter by year or range (e.g., 2020 or 1990-2000)
92        #[arg(long, short = 'y')]
93        year: Option<String>,
94        /// Filter by genre
95        #[arg(long, short = 'g')]
96        genre: Option<String>,
97        /// Filter by ISRC code (tracks only)
98        #[arg(long)]
99        isrc: Option<String>,
100        /// Filter by UPC code (albums only)
101        #[arg(long)]
102        upc: Option<String>,
103        /// Only albums released in the past two weeks
104        #[arg(long)]
105        new: bool,
106        /// Only albums with lowest 10% popularity
107        #[arg(long)]
108        hipster: bool,
109        /// Play the first result
110        #[arg(long, short = 'p')]
111        play: bool,
112        /// Sort results by fuzzy match score
113        #[arg(long, short = 's')]
114        sort: bool,
115    },
116    /// Manage playlists (alias: pl)
117    #[command(alias = "pl")]
118    Playlist {
119        #[command(subcommand)]
120        command: PlaylistCommand,
121    },
122    /// Manage your library (liked songs) (alias: lib)
123    #[command(alias = "lib")]
124    Library {
125        #[command(subcommand)]
126        command: LibraryCommand,
127    },
128    /// Get info about track, album, or artist (defaults to now playing) (alias: i)
129    #[command(alias = "i")]
130    Info {
131        #[command(subcommand)]
132        command: InfoCommand,
133    },
134    /// User profile and stats
135    User {
136        #[command(subcommand)]
137        command: UserCommand,
138    },
139    /// Manage podcasts (shows)
140    Show {
141        #[command(subcommand)]
142        command: ShowCommand,
143    },
144    /// Manage podcast episodes
145    Episode {
146        #[command(subcommand)]
147        command: EpisodeCommand,
148    },
149    /// Manage audiobooks
150    Audiobook {
151        #[command(subcommand)]
152        command: AudiobookCommand,
153    },
154    /// Manage saved albums
155    Album {
156        #[command(subcommand)]
157        command: AlbumCommand,
158    },
159    /// Get audiobook chapter details
160    Chapter {
161        #[command(subcommand)]
162        command: ChapterCommand,
163    },
164    /// Browse Spotify categories
165    Category {
166        #[command(subcommand)]
167        command: CategoryCommand,
168    },
169    /// Follow/unfollow artists and users
170    Follow {
171        #[command(subcommand)]
172        command: FollowCommand,
173    },
174    /// List available Spotify markets (countries)
175    Markets,
176    /// RPC daemon for external control (Neovim, scripts, etc.)
177    #[cfg(unix)]
178    Daemon {
179        #[command(subcommand)]
180        command: DaemonCommand,
181    },
182    /// Generate shell completions
183    Completions {
184        /// Shell to generate completions for
185        #[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}