feat(cli)!: replace the one-shot commands with a scriptable surface
The one-shot half of the binary could not finish its headline task without a human at the keyboard: `search` opened a full-screen picker, panicked with exit 101 when there was no terminal, and no command could fetch a known file at all. Everything exited 0 whatever happened, wrote emoji prose to stdout, and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml. The commands are now built for scripts and agents: search download get browse room list|say|listen message send|read stdout carries records only (tab-separated, or NDJSON under --json), stderr carries progress, and the exit code carries the verdict: 2 usage, 3 connect, 4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and fetch in one step, and `search --json | jq ... | download --stdin` composes. Global flags parse in any position and every one has a SOULSEEK_ variable and a config.toml key resolved through a single precedence chain. Defects found and fixed along the way, each covered by a test: - a download with an unknown size was finalized against zero bytes and saved as an empty file; the size now comes from the peer's share listing - transfers are keyed by a hash of the remote path, so the same path from two peers shadowed itself and two files sharing a basename overwrote each other; both collisions are now dropped with a warning - `message send` and `room say` slept 500ms and claimed success; they now wait for a server reply queued behind the message before reporting - Client::login waited on an unbounded channel and never returned when the server went quiet Deleted the TUI picker the old `search` drove (download_selector, MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The interactive TUI is otherwise untouched. BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and `message send|read`; `--disable-listener` becomes `--no-listener`; the unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no longer opens a picker or downloads anything.
This commit is contained in:
+495
-96
@@ -1,132 +1,531 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
//! 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;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
/// 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
|
||||
|
||||
Exit codes:
|
||||
0 success 2 usage/config 3 connect or login 4 no results
|
||||
5 timed out 6 transfer failed 1 unexpected error";
|
||||
|
||||
#[derive(Parser, Debug, Default)]
|
||||
#[command(
|
||||
name = "soulseek-rs",
|
||||
author,
|
||||
version,
|
||||
about = "Soulseek client in Rust 🦀",
|
||||
long_about = None
|
||||
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 {
|
||||
#[arg(short, long, env = "SOULSEEK_USERNAME")]
|
||||
pub username: Option<String>,
|
||||
|
||||
#[arg(short, long, env = "SOULSEEK_PASSWORD")]
|
||||
pub password: Option<String>,
|
||||
|
||||
/// Server address as host:port (default: server.slsknet.org:2416)
|
||||
#[arg(short, long, env = "SOULSEEK_SERVER")]
|
||||
pub server: Option<String>,
|
||||
|
||||
#[arg(long, env = "DISABLE_LISTENER")]
|
||||
pub disable_listener: bool,
|
||||
|
||||
/// Port to accept peer connections on (default: 2234)
|
||||
#[arg(short, long, env = "LISTENER_PORT")]
|
||||
pub listener_port: Option<u16>,
|
||||
|
||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||
pub verbose: u8,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "LOG_FILE",
|
||||
help = "Write logs to file instead of stderr"
|
||||
)]
|
||||
pub log_file: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
|
||||
/// Directory downloads are saved to (default: your Downloads/Soulseek
|
||||
/// folder, which is also shared unless shared_dir says otherwise)
|
||||
#[arg(short, long, env = "SOULSEEK_DOWNLOAD_DIR")]
|
||||
pub download_dir: Option<String>,
|
||||
/// 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,
|
||||
env = "SOULSEEK_SHARED_DIR",
|
||||
help = "Directory whose files are shared with (uploaded to) other peers"
|
||||
global = true,
|
||||
env = "SOULSEEK_PASSWORD_CMD",
|
||||
hide_env_values = true
|
||||
)]
|
||||
pub shared_dir: Option<String>,
|
||||
pub password_cmd: Option<String>,
|
||||
|
||||
/// Maximum simultaneous downloads (default: 5)
|
||||
#[arg(short = 'c', long, env = "MAX_CONCURRENT_DOWNLOADS")]
|
||||
/// 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 stays active (default: 10)
|
||||
#[arg(long)]
|
||||
/// 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>,
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
Search {
|
||||
query: String,
|
||||
/// Search the network and print matching files
|
||||
Search(SearchArgs),
|
||||
|
||||
#[arg(short, long, default_value = "10")]
|
||||
timeout: u64,
|
||||
/// Download files by user and remote path
|
||||
Download(DownloadArgs),
|
||||
|
||||
#[arg(short, long, default_value = "~/Downloads")]
|
||||
download_dir: String,
|
||||
/// Search, pick automatically, and download in one step
|
||||
Get(GetArgs),
|
||||
|
||||
#[arg(
|
||||
short = 'c',
|
||||
long,
|
||||
env = "MAX_CONCURRENT_DOWNLOADS",
|
||||
default_value = "5"
|
||||
)]
|
||||
max_concurrent_downloads: usize,
|
||||
},
|
||||
/// List the files a user shares
|
||||
Browse(BrowseArgs),
|
||||
|
||||
/// Send a private message to another user
|
||||
Message {
|
||||
/// Username of the recipient
|
||||
username: String,
|
||||
/// Chat rooms
|
||||
#[command(subcommand)]
|
||||
Room(RoomCommand),
|
||||
|
||||
/// Message text to send
|
||||
message: String,
|
||||
},
|
||||
/// Private messages
|
||||
#[command(subcommand)]
|
||||
Message(MessageCommand),
|
||||
|
||||
/// Browse another user's shared files
|
||||
Browse {
|
||||
/// Username whose shares to list
|
||||
username: String,
|
||||
},
|
||||
|
||||
/// List the public chat rooms and their user counts
|
||||
Rooms,
|
||||
|
||||
/// Join a chat room: print messages, or send one and exit
|
||||
Chat {
|
||||
/// Name of the room to join
|
||||
room: String,
|
||||
|
||||
/// Optional message to say in the room (omit to just listen)
|
||||
message: Option<String>,
|
||||
|
||||
/// Seconds to stay and print incoming messages when only listening
|
||||
#[arg(short, long, default_value = "30")]
|
||||
listen_secs: u64,
|
||||
},
|
||||
|
||||
/// Test whether your router lets us auto-open the listen port (UPnP/NAT-PMP)
|
||||
/// Check whether the router opens the listen port (UPnP/NAT-PMP)
|
||||
Portmap,
|
||||
}
|
||||
|
||||
pub fn parse_server_address(server: &str) -> color_eyre::Result<(String, u16)> {
|
||||
let parts: Vec<&str> = server.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(color_eyre::eyre::eyre!(
|
||||
"Invalid server format. Expected 'host:port', got '{}'",
|
||||
server
|
||||
/// How to order search results.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
|
||||
pub enum SortKey {
|
||||
/// Free slot first, then bitrate, peer speed, and size
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct SearchArgs {
|
||||
/// What to search for
|
||||
pub query: String,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// 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 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,
|
||||
},
|
||||
|
||||
/// 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, .. }))
|
||||
));
|
||||
}
|
||||
let host = parts[0].to_string();
|
||||
let port = parts[1].parse::<u16>().map_err(|_| {
|
||||
color_eyre::eyre::eyre!("Invalid port number: {}", parts[1])
|
||||
})?;
|
||||
Ok((host, port))
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user