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:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent c2dd227cee
commit be6581ee6c
17 changed files with 3756 additions and 1583 deletions
+495 -96
View File
@@ -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());
}
}