The window could show any peer's share tree but never this account's, so "am I actually sharing that?" had no answer short of asking a friend to browse you. The tree is already there; what was missing was feeding it from our own index. B opens it — the same collapsible tree, filter, and navigation the peer browse offers, built from `Shares::directories()`, which is the listing GetShareFileList answers with. So what it shows is what the network sees, not what the config meant to share. r re-scans the disk rather than re-asking a peer, o steps across to the folders that feed it, and nothing here queues a download from ourselves. Attached to a daemon the index lives on the daemon's host, so it comes back over a new `shares.files` method rather than being scanned locally; `shares files [--filter]` prints the same listing headless, through the `emit_listing` that `browse` now shares with it — the two are only comparable while they are built the same way. Two allocation fixes came out of the review, both on paths this reaches: the daemon hands its listing to the DTO instead of deep-copying one it already owns, and `Shares::directories()` allocates its map key once per folder rather than once per file, which the peer-facing serve path has been paying for all along.
923 lines
26 KiB
Rust
923 lines
26 KiB
Rust
//! 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<Commands>,
|
|
|
|
/// Soulseek username
|
|
#[arg(short, long, global = true, env = "SOULSEEK_USERNAME")]
|
|
pub username: Option<String>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// Server as host:port
|
|
#[arg(long, global = true, env = "SOULSEEK_SERVER")]
|
|
pub server: Option<String>,
|
|
|
|
/// 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<u16>,
|
|
|
|
/// Directory downloads are saved to
|
|
#[arg(short, long, global = true, env = "SOULSEEK_DOWNLOAD_DIR")]
|
|
pub download_dir: Option<String>,
|
|
|
|
/// Directory shared with other peers; repeat for several
|
|
#[arg(long, global = true, env = "SOULSEEK_SHARED_DIR", action = ArgAction::Append)]
|
|
pub shared_dir: Vec<String>,
|
|
|
|
/// Maximum simultaneous downloads
|
|
#[arg(
|
|
short = 'c',
|
|
long,
|
|
global = true,
|
|
env = "SOULSEEK_MAX_CONCURRENT_DOWNLOADS"
|
|
)]
|
|
pub max_concurrent_downloads: Option<usize>,
|
|
|
|
/// 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<u64>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// 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<PathBuf>,
|
|
|
|
/// 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<PathBuf>,
|
|
}
|
|
|
|
/// 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<DaemonCommand>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// How many uploads to serve at once
|
|
#[arg(long, value_parser = clap::value_parser!(u16).range(1..=1000))]
|
|
pub upload_slots: Option<u16>,
|
|
}
|
|
|
|
#[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<String>,
|
|
|
|
/// 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<Shell>,
|
|
}
|
|
|
|
/// 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<PathBuf>,
|
|
}
|
|
|
|
#[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<u8>,
|
|
}
|
|
|
|
#[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<String>,
|
|
},
|
|
|
|
/// 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<u32>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// Keep only this file extension, with or without a dot; repeat for several
|
|
#[arg(long, value_name = "EXT", action = ArgAction::Append)]
|
|
pub extension: Vec<String>,
|
|
|
|
/// Drop results smaller than this many bytes
|
|
#[arg(long, value_name = "BYTES")]
|
|
pub min_size: Option<u64>,
|
|
|
|
/// Drop results larger than this many bytes
|
|
#[arg(long, value_name = "BYTES")]
|
|
pub max_size: Option<u64>,
|
|
}
|
|
|
|
#[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<String>,
|
|
|
|
/// Remote path exactly as printed by `search` or `browse`
|
|
#[arg(required_unless_present = "stdin")]
|
|
pub path: Option<String>,
|
|
|
|
/// File size in bytes, as reported by `search` or `browse`
|
|
#[arg(long)]
|
|
pub size: Option<u64>,
|
|
|
|
/// 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<String>,
|
|
|
|
#[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::<u16>()
|
|
.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());
|
|
}
|
|
}
|