//! Command-line surface. //! //! Two modes share one binary: no subcommand launches the interactive TUI, //! any subcommand runs a one-shot operation designed for scripts and agents. //! The one-shot half follows three rules — stdout carries data only, stderr //! carries progress, and the exit code carries the verdict (see //! [`crate::output::Exit`]). use clap::builder::FalseyValueParser; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; use std::path::PathBuf; /// Upper bound for every wait expressed in seconds. A one-shot command that /// runs longer than a day is a mistyped flag, and rejecting it here keeps the /// deadline arithmetic in the command layer from overflowing. const MAX_WAIT_SECS: u64 = 24 * 60 * 60; const EXAMPLES: &str = "\ Examples: soulseek-rs launch the interactive TUI soulseek-rs search 'aphex twin xtal' print matching files soulseek-rs get 'aphex twin xtal' search, pick the best, download soulseek-rs search 'rjd2' --json | jq -c 'select(.bitrate >= 320)' \\ | soulseek-rs download --stdin filter, then fetch soulseek-rs browse someuser --json list a peer's shares soulseek-rs room listen lobby --follow stream room chat soulseek-rs serve --follow --json share files, stream upload events soulseek-rs daemon run as a service; later commands use it soulseek-rs user someuser is this peer online and worth it soulseek-rs skills install teach your coding agent this CLI soulseek-rs completions install tab completion for your shell Exit codes: 0 success 2 usage/config 3 connect or login 4 no results 5 timed out 6 transfer failed 7 session lost 1 unexpected error"; #[derive(Parser, Debug, Default)] #[command( name = "soulseek-rs", author, version, about = "Soulseek client in Rust 🦀", long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \ interactive TUI, or use a subcommand for a scriptable \ one-shot operation: stdout is data, stderr is progress, and \ the exit code says what happened.", after_help = EXAMPLES )] // A flag is a bool; a command line with several switches is not a struct that // wants splitting up. #[allow(clippy::struct_excessive_bools)] pub struct Cli { #[command(subcommand)] pub command: Option, /// Soulseek username #[arg(short, long, global = true, env = "SOULSEEK_USERNAME")] pub username: Option, /// Password (visible in `ps`; prefer --password-stdin or the OS keychain) #[arg( short, long, global = true, env = "SOULSEEK_PASSWORD", hide_env_values = true )] pub password: Option, /// Read the password from the first line of stdin (takes precedence /// over --password) #[arg(long, global = true)] pub password_stdin: bool, /// Shell command whose stdout is the password, used when nothing else /// supplies one #[arg( long, global = true, env = "SOULSEEK_PASSWORD_CMD", hide_env_values = true )] pub password_cmd: Option, /// Server as host:port #[arg(long, global = true, env = "SOULSEEK_SERVER")] pub server: Option, /// Do not accept incoming peer connections #[arg( long, global = true, env = "SOULSEEK_NO_LISTENER", action = ArgAction::SetTrue, value_parser = FalseyValueParser::new() )] pub no_listener: bool, /// Accept incoming peer connections, overriding the config file #[arg(long, global = true, action = ArgAction::SetTrue)] pub listener: bool, /// Port to accept peer connections on #[arg(long, global = true, env = "SOULSEEK_LISTENER_PORT")] pub listener_port: Option, /// Directory downloads are saved to #[arg(short, long, global = true, env = "SOULSEEK_DOWNLOAD_DIR")] pub download_dir: Option, /// Directory shared with other peers; repeat for several #[arg(long, global = true, env = "SOULSEEK_SHARED_DIR", action = ArgAction::Append)] pub shared_dir: Vec, /// Maximum simultaneous downloads #[arg( short = 'c', long, global = true, env = "SOULSEEK_MAX_CONCURRENT_DOWNLOADS" )] pub max_concurrent_downloads: Option, /// Seconds a search collects results before reporting #[arg( long, global = true, env = "SOULSEEK_SEARCH_TIMEOUT", value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] pub search_timeout: Option, /// Control a daemon at this address (host:port) or Unix socket path #[arg(long, global = true, env = "SOULSEEK_DAEMON", value_name = "ADDR")] pub daemon: Option, /// Token for a remote daemon; a local socket needs none #[arg( long, global = true, env = "SOULSEEK_DAEMON_TOKEN", hide_env_values = true )] pub daemon_token: Option, /// Ignore any running daemon and open this run's own session #[arg(long, global = true, action = ArgAction::SetTrue)] pub no_daemon: bool, /// Config file to read instead of the default location #[arg(long, global = true, env = "SOULSEEK_CONFIG", value_name = "FILE")] pub config: Option, /// Ignore the config file entirely #[arg(long, global = true, action = ArgAction::SetTrue)] pub no_config: bool, /// Emit newline-delimited JSON instead of tab-separated text #[arg(long, global = true, action = ArgAction::SetTrue)] pub json: bool, /// Suppress progress and status output on stderr #[arg(short, long, global = true, action = ArgAction::SetTrue)] pub quiet: bool, /// Increase log verbosity (repeat up to -vvvv) #[arg(short, long, global = true, action = ArgAction::Count)] pub verbose: u8, /// Write logs to a file instead of stderr #[arg(long, global = true, env = "SOULSEEK_LOG_FILE")] pub log_file: Option, } /// Non-exhaustive: this enum is public through the crate's library target so /// the integration tests can reach it, and a new subcommand must not read as a /// breaking change of that library. #[derive(Subcommand, Debug)] #[non_exhaustive] pub enum Commands { /// Search the network and print matching files Search(SearchArgs), /// Download files by user and remote path Download(DownloadArgs), /// Transfers in flight #[command(subcommand)] Transfer(TransferCommand), /// Search, pick automatically, and download in one step Get(GetArgs), /// List the files a user shares Browse(BrowseArgs), /// Standing searches the server re-runs on its own schedule #[command(subcommand)] Wish(WishCommand), /// Chat rooms #[command(subcommand)] Room(RoomCommand), /// Private messages #[command(subcommand)] Message(MessageCommand), /// Stay online sharing files, reporting each upload as it happens Serve(ServeArgs), /// Run as a service other clients connect to, or control one that is Daemon(DaemonArgs), /// What the server knows about another user User(UserArgs), /// Keep a daemon updated about users you care about #[command(subcommand)] Watch(WatchCommand), /// Confirm the credentials and connection work Whoami, /// This account: its password, and what this machine stores for it #[command(subcommand)] Account(AccountCommand), /// The folders this client shares #[command(subcommand)] Shares(SharesCommand), /// Read and write the config file #[command(subcommand)] Config(ConfigCommand), /// Check whether the router opens the listen port (UPnP/NAT-PMP) Portmap, /// Teach a local coding agent this command surface #[command(subcommand)] Skills(SkillsCommand), /// Tab completion for bash, zsh, and fish #[command(subcommand)] Completions(CompletionsCommand), } #[derive(Args, Debug, Default)] pub struct DaemonArgs { #[command(subcommand)] pub command: Option, /// Also accept control connections on this address, for clients on other /// machines. They need the token; the local socket does not. #[arg(long, value_name = "ADDR")] pub bind: Option, /// How many uploads to serve at once #[arg(long, value_parser = clap::value_parser!(u16).range(1..=1000))] pub upload_slots: Option, } #[derive(Subcommand, Debug)] #[non_exhaustive] pub enum DaemonCommand { /// Print the token remote clients authenticate with Token, /// What a running daemon is doing Status, /// Ask a running daemon to shut down Stop, } #[derive(Subcommand, Debug)] #[non_exhaustive] pub enum AccountCommand { /// Change the account's password on the server Password(PasswordArgs), /// Forget the password stored on this machine, so the next start asks /// for it again Logout, } #[derive(Args, Debug)] pub struct PasswordArgs { /// The new password (visible in `ps`; prefer --new-password-stdin) #[arg( long, value_name = "PASSWORD", env = "SOULSEEK_NEW_PASSWORD", hide_env_values = true )] pub new_password: Option, /// Read the new password from the first line of stdin (takes precedence /// over --new-password) #[arg(long, action = ArgAction::SetTrue)] pub new_password_stdin: bool, } #[derive(Subcommand, Debug)] #[non_exhaustive] pub enum CompletionsCommand { /// Write the completion script where the shell looks for it Install(CompletionsArgs), /// Remove the completion script again Uninstall(CompletionsArgs), } #[derive(Args, Debug)] pub struct CompletionsArgs { /// Shell to act on instead of the ones found on this machine; repeat for /// several #[arg(long, value_enum, action = ArgAction::Append)] pub shell: Vec, } /// The shells with a conventional per-user completions location. #[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] pub enum Shell { Bash, Zsh, Fish, } #[derive(Subcommand, Debug)] pub enum SkillsCommand { /// Write the skill into every agent found on this machine, replacing an /// older copy Install(SkillsArgs), /// Remove the skill again Uninstall(SkillsArgs), /// Show where the skill goes and what is there now List(SkillsArgs), } #[derive(Args, Debug)] pub struct SkillsArgs { /// Skills directory to use instead of the agents found on this machine; /// repeat for several. Use `--dir .claude/skills` to commit the skill /// alongside a project #[arg(long, value_name = "PATH", action = ArgAction::Append)] pub dir: Vec, } #[derive(Args, Debug)] pub struct ServeArgs { /// Seconds to stay online before exiting #[arg( long, default_value_t = 3600, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] pub duration: u64, /// Stay online until interrupted, ignoring --duration #[arg(long, action = ArgAction::SetTrue)] pub follow: bool, /// Also re-run the wishlist on the interval the server sets, reporting /// matches as they arrive #[arg(long, action = ArgAction::SetTrue)] pub wishlist: bool, /// How many uploads to run at once [default: 10]; the rest queue, /// privileged users first #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..=64))] pub upload_slots: Option, } #[derive(Args, Debug)] pub struct UserArgs { /// The user to ask the server about pub user: String, /// Seconds to wait for the server's answer #[arg( short, long, default_value_t = 10, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] pub timeout: u64, } #[derive(Subcommand, Debug)] pub enum SharesCommand { /// List the configured share folders List, /// Add a folder to the shares and save it to the config file Add { /// Folder to start sharing directory: String, }, /// Stop sharing a folder and save that to the config file Remove { /// Folder to stop sharing directory: String, }, /// List every indexed file, the way `browse` lists a peer's Files { /// Only paths containing this text, case-insensitively #[arg(long)] filter: Option, }, /// Re-index the shares and report what the network will see Reindex, /// Report how many folders and files are indexed Status, } #[derive(Subcommand, Debug)] pub enum ConfigCommand { /// Print the path of the config file in use Path, /// Print every setting and its effective value List, /// Print one setting Get { /// Setting name, as shown by `config list` key: String, }, /// Change one setting in the config file Set { /// Setting name, as shown by `config list` key: String, /// New value; an empty string clears it value: String, }, } /// How to order search results. #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)] pub enum SortKey { /// Free slot first, then bitrate, peer speed, and size #[default] Best, /// Largest file first Size, /// Highest bitrate first Bitrate, /// Fastest peer first Speed, } /// Which of the ranked results `get` should download. #[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] pub enum Pick { /// The single best-ranked result Best, /// The first result that arrives, without waiting out the search First, /// Every result, subject to --limit All, } /// The result restrictions every command that searches accepts, so `search`, /// `get` and `wish run` all narrow a result set the same way. #[derive(Args, Debug, Default, Clone)] pub struct FilterArgs { /// Drop results below this bitrate in kbps #[arg(long, value_name = "KBPS")] pub min_bitrate: Option, /// Keep only peers advertising a free upload slot #[arg(long, action = ArgAction::SetTrue)] pub free_slots: bool, /// Drop results whose path contains this term; repeat for several #[arg(long, value_name = "TERM", action = ArgAction::Append)] pub exclude: Vec, /// Keep only this file extension, with or without a dot; repeat for several #[arg(long, value_name = "EXT", action = ArgAction::Append)] pub extension: Vec, /// Drop results smaller than this many bytes #[arg(long, value_name = "BYTES")] pub min_size: Option, /// Drop results larger than this many bytes #[arg(long, value_name = "BYTES")] pub max_size: Option, } #[derive(Args, Debug)] pub struct SearchArgs { /// What to search for pub query: String, #[command(flatten)] pub filter: FilterArgs, /// Print at most this many results (0 means all) #[arg(short = 'n', long, default_value_t = 0)] pub limit: usize, /// Result order #[arg(long, value_enum, default_value_t = SortKey::Best)] pub sort: SortKey, } #[derive(Args, Debug)] pub struct DownloadArgs { /// Peer holding the file #[arg(required_unless_present = "stdin")] pub user: Option, /// Remote path exactly as printed by `search` or `browse` #[arg(required_unless_present = "stdin")] pub path: Option, /// File size in bytes, as reported by `search` or `browse` #[arg(long)] pub size: Option, /// Read records from stdin: `search --json` output, or TAB-separated /// `search` text lines #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["user", "path", "size"])] pub stdin: bool, /// Seconds to wait for each transfer before giving up #[arg( short, long, default_value_t = 300, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] pub timeout: u64, } #[derive(Args, Debug)] pub struct GetArgs { /// What to search for pub query: String, /// Which results to download #[arg(long, value_enum, default_value_t = Pick::Best)] pub pick: Pick, /// With `--pick all`, download at most this many (0 means all) #[arg(short = 'n', long, default_value_t = 0)] pub limit: usize, #[command(flatten)] pub filter: FilterArgs, /// Seconds to wait for each transfer before giving up #[arg( short, long, default_value_t = 300, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] pub timeout: u64, } #[derive(Args, Debug)] pub struct BrowseArgs { /// User whose shares to list pub user: String, /// Seconds to wait for the listing #[arg( short, long, default_value_t = 20, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] pub timeout: u64, } #[derive(Subcommand, Debug)] pub enum WishCommand { /// Store a query and search for it from now on Add { /// What to keep looking for query: String, }, /// Stop looking for a query Remove { /// The wish to drop, as shown by `wish list` query: String, }, /// Print the stored queries List, /// Search every stored query once and print what came back Run(WishRunArgs), } #[derive(Args, Debug)] pub struct WishRunArgs { /// Only run this wish instead of all of them #[arg(long, value_name = "QUERY")] pub only: Option, #[command(flatten)] pub filter: FilterArgs, /// Print at most this many results per wish (0 means all) #[arg(short = 'n', long, default_value_t = 0)] pub limit: usize, /// Result order #[arg(long, value_enum, default_value_t = SortKey::Best)] pub sort: SortKey, } #[derive(Subcommand, Debug)] pub enum TransferCommand { /// Stop a download or upload; a cancelled download's partial file is /// deleted Cancel { /// The other side of the transfer user: String, /// Remote path exactly as printed by `search`, `browse`, or `serve` path: String, }, } /// Watching only lasts as long as the session doing it, so these are aimed at /// a running daemon: it stays connected and keeps receiving the status pushes. #[derive(Subcommand, Debug)] pub enum WatchCommand { /// Start watching a user Add { /// The user to watch user: String, }, /// Stop watching a user Remove { /// The user to stop watching user: String, }, /// List the users being watched List, } #[derive(Subcommand, Debug)] pub enum RoomCommand { /// List public rooms and their user counts List { /// Seconds to wait for the list #[arg( short, long, default_value_t = 5, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] timeout: u64, }, /// Post a message to a room Say { /// Room to post in room: String, /// Message text message: String, }, /// List who is currently in a room Users { /// Room whose members to list room: String, /// Seconds to wait for the membership list #[arg( short, long, default_value_t = 10, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] timeout: u64, }, /// Stream messages and joins/leaves from a room Listen { /// Room to listen to room: String, /// Seconds to listen before exiting #[arg( long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] duration: u64, /// Listen until interrupted, ignoring --duration #[arg(long, action = ArgAction::SetTrue)] follow: bool, }, } #[derive(Subcommand, Debug)] pub enum MessageCommand { /// Send a private message Send { /// Recipient username user: String, /// Message text message: String, }, /// Stream incoming private messages Read { /// Seconds to listen before exiting #[arg( long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) )] duration: u64, /// Listen until interrupted, ignoring --duration #[arg(long, action = ArgAction::SetTrue)] follow: bool, }, } /// Split `host:port`, the only shape the server setting accepts. pub fn parse_server_address(server: &str) -> Result<(String, u16), String> { let (host, port) = server.rsplit_once(':').ok_or_else(|| { format!("Invalid server '{server}': expected host:port") })?; if host.is_empty() { return Err(format!("Invalid server '{server}': missing host")); } let port = port .parse::() .map_err(|_| format!("Invalid server '{server}': bad port '{port}'"))?; Ok((host.to_string(), port)) } #[cfg(test)] mod tests { use super::*; use clap::CommandFactory; fn parse(args: &[&str]) -> Cli { Cli::try_parse_from(args).expect("should parse") } #[test] fn clap_definitions_are_valid() { Cli::command().debug_assert(); } #[test] fn no_subcommand_means_tui() { assert!(parse(&["soulseek-rs"]).command.is_none()); } #[test] fn global_flags_parse_after_the_subcommand() { let cli = parse(&[ "soulseek-rs", "search", "query", "--username", "alice", "--json", "--search-timeout", "30", ]); assert_eq!(cli.username.as_deref(), Some("alice")); assert!(cli.json); assert_eq!(cli.search_timeout, Some(30)); } #[test] fn global_flags_parse_before_the_subcommand() { let cli = parse(&["soulseek-rs", "--username", "alice", "browse", "bob"]); assert_eq!(cli.username.as_deref(), Some("alice")); assert!(matches!(cli.command, Some(Commands::Browse(_)))); } #[test] fn shared_dir_repeats_into_a_list() { let cli = parse(&["soulseek-rs", "--shared-dir", "/a", "--shared-dir", "/b"]); assert_eq!(cli.shared_dir, vec!["/a".to_string(), "/b".to_string()]); } #[test] fn download_requires_user_and_path_without_stdin() { assert!(Cli::try_parse_from(["soulseek-rs", "download"]).is_err()); assert!( Cli::try_parse_from(["soulseek-rs", "download", "bob"]).is_err() ); assert!( Cli::try_parse_from(["soulseek-rs", "download", "bob", "a\\b.mp3"]) .is_ok() ); } #[test] fn download_stdin_needs_no_positionals() { let cli = parse(&["soulseek-rs", "download", "--stdin"]); let Some(Commands::Download(args)) = cli.command else { panic!("expected download"); }; assert!(args.stdin); assert!(args.user.is_none()); } #[test] fn download_stdin_conflicts_with_positionals() { assert!( Cli::try_parse_from([ "soulseek-rs", "download", "--stdin", "bob", "a.mp3" ]) .is_err() ); } #[test] fn room_and_message_groups_parse() { assert!(matches!( parse(&["soulseek-rs", "room", "list"]).command, Some(Commands::Room(RoomCommand::List { .. })) )); assert!(matches!( parse(&["soulseek-rs", "room", "say", "lobby", "hi"]).command, Some(Commands::Room(RoomCommand::Say { .. })) )); assert!(matches!( parse(&["soulseek-rs", "message", "read", "--follow"]).command, Some(Commands::Message(MessageCommand::Read { follow: true, .. })) )); } #[test] fn skills_dir_repeats_into_a_list() { let cli = parse(&[ "soulseek-rs", "skills", "install", "--dir", "/a", "--dir", "/b", ]); let Some(Commands::Skills(SkillsCommand::Install(args))) = cli.command else { panic!("expected skills install"); }; assert_eq!(args.dir, [PathBuf::from("/a"), PathBuf::from("/b")]); } #[test] fn get_defaults_to_picking_the_best_result() { let cli = parse(&["soulseek-rs", "get", "query"]); let Some(Commands::Get(args)) = cli.command else { panic!("expected get"); }; assert_eq!(args.pick, Pick::Best); assert_eq!(args.limit, 0); assert_eq!(args.timeout, 300); } #[test] fn an_absurd_wait_is_rejected_rather_than_overflowing_a_deadline() { assert!( Cli::try_parse_from([ "soulseek-rs", "download", "bob", "a.mp3", "--timeout", "99999999999999" ]) .is_err() ); assert!( Cli::try_parse_from([ "soulseek-rs", "--search-timeout", "0", "search", "q" ]) .is_err() ); assert!( Cli::try_parse_from(["soulseek-rs", "browse", "bob", "-t", "20"]) .is_ok() ); } #[test] fn server_address_splits_host_and_port() { assert_eq!( parse_server_address("server.slsknet.org:2416"), Ok(("server.slsknet.org".to_string(), 2416)) ); assert!(parse_server_address("noport").is_err()); assert!(parse_server_address("host:70000").is_err()); assert!(parse_server_address(":2416").is_err()); } }