From be6581ee6c32e1d80036a88cc01606d743d5aa48 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Sat, 25 Jul 2026 00:33:16 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 7 +- README.md | 205 ++++- soulseek-rs-lib/src/client/connection.rs | 14 +- soulseek-rs/src/cli.rs | 591 +++++++++++--- soulseek-rs/src/commands/mod.rs | 229 ++++++ soulseek-rs/src/commands/social.rs | 297 +++++++ soulseek-rs/src/commands/transfer.rs | 840 +++++++++++++++++++ soulseek-rs/src/config.rs | 14 - soulseek-rs/src/main.rs | 579 ++++---------- soulseek-rs/src/output.rs | 436 ++++++++++ soulseek-rs/src/persist/config.rs | 84 +- soulseek-rs/src/port_mapping/mod.rs | 80 +- soulseek-rs/src/ui/download_selector.rs | 636 --------------- soulseek-rs/src/ui/downloads.rs | 340 +------- soulseek-rs/src/ui/mod.rs | 4 +- soulseek-rs/src/ui/utils.rs | 6 - soulseek-rs/tests/cli_e2e.rs | 977 +++++++++++++++++++++++ 17 files changed, 3756 insertions(+), 1583 deletions(-) create mode 100644 soulseek-rs/src/commands/mod.rs create mode 100644 soulseek-rs/src/commands/social.rs create mode 100644 soulseek-rs/src/commands/transfer.rs delete mode 100644 soulseek-rs/src/config.rs create mode 100644 soulseek-rs/src/output.rs delete mode 100644 soulseek-rs/src/ui/download_selector.rs create mode 100644 soulseek-rs/tests/cli_e2e.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31b97f4..d1c6af6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,12 @@ jobs: test -x soulfind/bin/soulfind echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV" - - name: Run e2e tests (required against soulfind) + - name: Run library e2e tests (required against soulfind) env: SOULSEEK_E2E_REQUIRED: "1" run: cargo test -p soulseek-rs-lib --test e2e -- --nocapture + + - name: Run CLI e2e tests (required against soulfind) + env: + SOULSEEK_E2E_REQUIRED: "1" + run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture diff --git a/README.md b/README.md index 2624529..3f1f06c 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ music enthusiasts around the world to share niche music. connections when a peer can't be reached directly - **Automatic port mapping** — opens your listen port via UPnP-IGD and NAT-PMP, with a `portmap` subcommand to test your router -- **TUI and CLI** — a full terminal interface, plus scriptable subcommands - (`search`, `message`, `browse`, `rooms`, `chat`, `portmap`) +- **TUI and CLI** — a full terminal interface, plus a one-shot command surface + built for scripts and agents: every feature is reachable without a terminal, + stdout carries records only, and the exit code says what happened ## Project Goals @@ -87,16 +88,158 @@ soulseek-rs-lib = "6" ## Usage +Run it with no subcommand for the interactive TUI: + ```bash -./target/release/soulseek-rs "the weeknd Blinding Lights" +soulseek-rs ``` +Everything else is a one-shot command that runs headless. + +### Scripting + +The one-shot commands follow three rules, so they compose with other tools: + +- **stdout is data.** One record per line, and nothing else. Progress, + warnings, and errors go to stderr; `--quiet` silences the progress. +- **`--json` emits newline-delimited JSON**, one object per line. Without it, + records are tab-separated fields with no header and no decoration. +- **The exit code is the verdict.** No output plus exit 0 never means failure. + +| Code | Meaning | +| ---- | ------- | +| 0 | success | +| 1 | unexpected error | +| 2 | bad arguments, missing credentials, unusable configuration | +| 3 | could not reach the server, or the login was rejected | +| 4 | the command worked but found nothing | +| 5 | timed out waiting for a response or a transfer | +| 6 | a transfer started but did not finish | + +#### Commands + +```bash +soulseek-rs search # print matching files +soulseek-rs download # fetch one known file +soulseek-rs download --stdin # fetch files listed on stdin +soulseek-rs get # search, pick, and download +soulseek-rs browse # list a user's shared files +soulseek-rs room list # public rooms and user counts +soulseek-rs room say # post to a room +soulseek-rs room listen # stream room messages and joins +soulseek-rs message send # send a private message +soulseek-rs message read # stream incoming private messages +soulseek-rs portmap # test automatic port mapping +``` + +Every command emits records except the two that perform an action — +`room say` and `message send` say nothing and answer with their exit code +alone. `room listen` and `message read` stream records as they arrive, until +`--duration` seconds pass or, with `--follow`, until they are interrupted. + +#### Record shapes + +Text mode puts the identifying fields first and the remote path last, because +a path may contain anything except a tab (tabs and newlines in peer-supplied +text are replaced with spaces so a record is always exactly one line): + +``` +search user size bitrate path +browse user size path +room users room +download local-path +``` + +`download --stdin` reads back either shape it emits — a JSON object per line, +or tab-separated text whose first field is the user and last field is the +path — so `search` and `browse` pipe straight into it. + +#### Recipes + +```bash +# Everything above 320kbps from peers with a free slot, best first +soulseek-rs search "aphex twin xtal" --min-bitrate 320 --free-slots + +# One command, start to finish: search, pick the best, download it +soulseek-rs get "aphex twin xtal" + +# Filter with jq, then fetch what survived +soulseek-rs search "rjd2" --json \ + | jq -c 'select(.size > 5000000 and .free_slot)' \ + | soulseek-rs download --stdin + +# Grab a peer's whole FLAC folder +soulseek-rs browse someuser | grep -i '\.flac$' | soulseek-rs download --stdin + +# Branch on the outcome +if soulseek-rs get "some rare track" --quiet; then + echo "got it" +elif [ $? -eq 4 ]; then + echo "nobody has it" +fi + +# Log room chat as JSON until interrupted +soulseek-rs room listen lobby --follow --json >> lobby.ndjson +``` + +Downloads run under a deadline (`--timeout`, 300s by default) and +`--max-concurrent-downloads` at a time, so an unattended run always ends. + +### Configuration + +Every setting can come from a flag, an environment variable, or the config +file, in that order of precedence. Flags work before or after the subcommand. + +| Flag | Environment variable | `config.toml` key | +| ---- | -------------------- | ----------------- | +| `--username` | `SOULSEEK_USERNAME` | `username` | +| `--password` | `SOULSEEK_PASSWORD` | — (never stored in the file) | +| `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | +| `--server` | `SOULSEEK_SERVER` | `server` | +| `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | +| `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | +| `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | +| `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | +| `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | +| `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | +| `--log-file` | `SOULSEEK_LOG_FILE` | — | + +Boolean environment variables accept the usual `1`/`0`/`true`/`false`/`yes`/`no`. +`--config ` (or `SOULSEEK_CONFIG`) reads a different config file and +`--no-config` ignores it entirely, which is what an isolated or containerised +run wants. The config file lives at `~/.config/soulseek-rs/config.toml` on +macOS and Linux; `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate the +config and state directories wholesale. + +#### Credentials for unattended runs + +Four ways to supply a password, in the order they are consulted: + +1. `--password-stdin` — read the first line of stdin, like `docker login`. +2. `--password` / `SOULSEEK_PASSWORD` — convenient, but visible in `ps`. +3. The **OS keychain** (macOS Keychain, Windows Credential Manager, Linux + Secret Service), under service `soulseek-rs` and your username. The TUI + stores your password here after a successful login. +4. `password_cmd` / `--password-cmd` — a shell command whose stdout is the + password, e.g. `pass show soulseek`. Best for headless boxes with no + keychain. + +```bash +# Nothing sensitive on the command line or in the environment +pass show soulseek | soulseek-rs --username alice --password-stdin get "some track" +``` + +Logging stays at errors only unless you ask for more: `-v` through `-vvvv` +raise it, and `LOG_LEVEL`/`RUST_LOG` are honoured when no `-v` is given. +`NO_COLOR` and a non-terminal stderr both disable colour. + ### Private messages -Send a private message to another user from the command line: +Send and read private messages from the command line: ```bash -soulseek-rs message "hello there" +soulseek-rs message send "hello there" +soulseek-rs message read --duration 60 # or --follow to run until killed ``` In the interactive TUI: @@ -111,9 +254,9 @@ In the interactive TUI: From the command line: ```bash -soulseek-rs rooms # list public rooms with user counts -soulseek-rs chat # join and print messages for a while -soulseek-rs chat "hello room" # join, say one message, and exit +soulseek-rs room list # public rooms with user counts +soulseek-rs room listen # stream messages, joins, and leaves +soulseek-rs room say "hello room" # post one message and exit ``` In the interactive TUI, press `c` to open the chat-rooms popup: @@ -137,21 +280,25 @@ and **NAT-PMP**, so firewalled peers and the server can connect back to you. This is best-effort: if your router has UPnP/NAT-PMP disabled it's a no-op and you'll see a log line suggesting you forward the port manually. -- The mapped/forwarded port is your `--listener-port` (env `LISTENER_PORT`, - default `2234`); it is renewed automatically and removed on exit. +- The mapped/forwarded port is your `--listener-port` (env + `SOULSEEK_LISTENER_PORT`, default `2234`); it is renewed automatically and + removed on exit. - If auto-mapping can't get that exact port, forward **TCP 2234** (or whatever `--listener-port` you chose) to this machine on your router. -- Pass `--disable-listener` to turn the listener (and port mapping) off. +- Pass `--no-listener` to turn the listener (and port mapping) off, or + `--listener` to force it on when the config file disables it. Check whether it works on **your** network without launching the whole client: ```bash -soulseek-rs portmap +soulseek-rs portmap # exits 0 when mapping works, 4 when it does not +soulseek-rs portmap --json # {"ok":true,"backend":"upnp","external":"…"} ``` -It tries to open the port via UPnP/NAT-PMP and prints whether your router -allowed it (and your external address), then removes the test mapping. If it -reports failure, enable UPnP on your router or forward the port manually. +It tries to open the port via UPnP/NAT-PMP, reports whether your router allowed +it (and your external address), then removes the test mapping. Because the +verdict is in the exit code, `soulseek-rs portmap || notify-me` works as a +health check. If both you and a peer are behind routers with no forwarded port, browsing that peer can't work — that's a fundamental Soulseek/peer-to-peer limitation, not a @@ -185,10 +332,13 @@ cargo fmt ### End-to-end tests -The `soulseek-rs-lib/tests/e2e.rs` suite exercises the client against a real -Soulseek server using [soulfind](https://github.com/soulfind-dev/soulfind), a -local server implementation. The tests are **server-optional**: they run when a -server is available and otherwise skip (so `cargo test` stays green everywhere). +Two suites exercise the client against a real Soulseek server using +[soulfind](https://github.com/soulfind-dev/soulfind), a local server +implementation: `soulseek-rs-lib/tests/e2e.rs` covers the protocol library, and +`soulseek-rs/tests/cli_e2e.rs` drives the actual binary the way a script would +(records on stdout, exit codes, real searches, downloads, and chat). The tests +are **server-optional**: they run when a server is available and otherwise skip +(so `cargo test` stays green everywhere). They locate a server in this order: @@ -200,12 +350,14 @@ They locate a server in this order: ```bash # Build soulfind once (see its BUILDING.md), then: SOULFIND_BIN=/path/to/soulfind/bin/soulfind \ - cargo test -p soulseek-rs-lib --test e2e -- --nocapture + cargo test --workspace -- --nocapture ``` -> On macOS, soulfind's `SQLITE_CONFIG_SINGLETHREAD` optimization is rejected by -> the system SQLite; building soulfind with that call made non-fatal lets it run -> locally. It is only a mutex optimization and does not affect the server. +> On macOS, soulfind's `sqlite3_config` call is rejected by the system SQLite +> and the server dies at startup with `SQLite error 7 (out of memory)` — which +> the suite reports as a skip. Run the tests with +> `DYLD_LIBRARY_PATH=/opt/homebrew/opt/sqlite/lib` so soulfind picks up +> Homebrew's SQLite instead; no patching needed. Set `SOULSEEK_E2E_REQUIRED=1` to turn a missing server into a hard failure instead of a skip. Continuous integration sets it so the e2e suite genuinely @@ -217,9 +369,10 @@ runs against a freshly built soulfind rather than silently skipping. - **Format & Clippy** — `cargo fmt --all --check` and `cargo clippy --workspace --all-targets -- -D warnings`. -- **Test** — builds soulfind from source (LDC + `dub build :server`), points - `SOULFIND_BIN` at it, and runs `cargo test --workspace` with - `SOULSEEK_E2E_REQUIRED=1`, so unit and end-to-end tests both run. +- **Test** — `cargo test --verbose` on Linux, macOS, and Windows. +- **End-to-end** — builds soulfind from source (LDC + `dub build :server`), + points `SOULFIND_BIN` at it, and runs both the library and CLI e2e suites + with `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping. ## License diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index dbd23c7..3fd2e13 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -5,6 +5,11 @@ use super::{ TcpStream, debug, error, info, mpsc, thread, trace, }; +/// Ceiling on the wait for a login verdict. Generous enough for a slow server +/// and a retrying connect, short enough that an unattended caller ends. +const LOGIN_RESPONSE_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(45); + impl Client { pub fn connect(&mut self) -> Result<()> { let (sender, message_reader): ( @@ -89,6 +94,13 @@ impl Client { Ok(()) } + /// Log in and wait for the server's verdict. + /// + /// The wait is bounded: the actor only answers once it has a working + /// connection, so a server that never completes the TCP handshake would + /// otherwise block the caller forever. A caller that hits + /// [`SoulseekRs::Timeout`] can retry or give up, but it always regains + /// control. pub fn login(&self) -> Result { info!("Logging in as {}", self.username); if let Some(handle) = &self.server_handle { @@ -99,7 +111,7 @@ impl Client { response: tx, }); - match rx.recv() { + match rx.recv_timeout(LOGIN_RESPONSE_TIMEOUT) { Ok(result) => result, Err(_) => Err(SoulseekRs::Timeout), } diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 8603e2e..81247c7 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -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, - - #[arg(short, long, env = "SOULSEEK_PASSWORD")] - pub password: Option, - - /// Server address as host:port (default: server.slsknet.org:2416) - #[arg(short, long, env = "SOULSEEK_SERVER")] - pub server: Option, - - #[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, - - #[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, - #[command(subcommand)] pub command: Option, - /// 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, + /// 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, - 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, + pub password_cmd: Option, - /// 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, + + /// 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 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, + + /// 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, } #[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, - - /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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::() + .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::().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()); + } } diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs new file mode 100644 index 0000000..171909c --- /dev/null +++ b/soulseek-rs/src/commands/mod.rs @@ -0,0 +1,229 @@ +//! One-shot commands: everything the binary can do without a terminal. +//! +//! Each command opens a [`Session`], does one job, writes records to stdout +//! and progress to stderr, and returns an exit code through [`CliError`]. + +pub mod social; +pub mod transfer; + +use crate::cli::{Commands, MessageCommand, RoomCommand}; +use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord}; +use crate::port_mapping::{self, PortMapper}; +use soulseek_rs::types::{RoomEvent, RoomInfo}; +use soulseek_rs::{Client, ClientSettings}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Everything a one-shot command needs that does not come from its own flags. +pub struct Ctx { + pub out: Out, + pub settings: ClientSettings, + pub download_dir: String, + pub max_concurrent_downloads: usize, + pub search_timeout: Duration, +} + +/// A logged-in client plus, while the listener is enabled, the port mapping +/// that makes us reachable. Both live until the command ends. +pub struct Session { + pub client: Arc, + _mapper: Option, +} + +/// How long to wait for the TCP handshake with the server. +const REACH_TIMEOUT: Duration = Duration::from_secs(10); +/// How long to wait for the login exchange once the socket is open. +const LOGIN_TIMEOUT: Duration = Duration::from_secs(30); + +impl Session { + pub fn open(ctx: &Ctx) -> CliResult { + let mapper = ctx + .settings + .enable_listen + .then(|| PortMapper::spawn(ctx.settings.listen_port)); + + let address = ctx.settings.server_address.to_string(); + ctx.out.status(&format!("connecting to {address}…")); + reachable(&address, REACH_TIMEOUT)?; + + let client = login(ctx.settings.clone(), &address)?; + ctx.out + .status(&format!("logged in as {}", ctx.settings.username)); + + Ok(Self { + client: Arc::new(client), + _mapper: mapper, + }) + } +} + +/// Check the server answers at all before handing the socket to the client. +/// +/// The connect and login the library performs happen on its own threads and +/// report a refused connection only indirectly, so an unattended run would sit +/// there. One handshake up front turns that into an immediate, accurate error. +fn reachable(address: &str, timeout: Duration) -> CliResult { + let resolved: Vec = address + .to_socket_addrs() + .map_err(|e| { + CliError::connection(format!("cannot resolve {address}: {e}")) + })? + .collect(); + + let mut last = None; + for candidate in &resolved { + match TcpStream::connect_timeout(candidate, timeout) { + Ok(_) => return Ok(()), + Err(e) => last = Some(e), + } + } + Err(CliError::connection(match last { + Some(e) => format!("cannot reach {address}: {e}"), + None => format!("cannot reach {address}: no address resolved"), + })) +} + +/// Connect and log in under a deadline. +/// +/// `Client::login` blocks on a channel the library only answers once its actor +/// has a working connection, so it is run on its own thread: a server that +/// accepts the socket and then says nothing must still end the command. +fn login(settings: ClientSettings, address: &str) -> CliResult { + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut client = Client::with_settings(settings); + let outcome = match client.connect() { + Ok(()) => client.login(), + Err(e) => Err(e), + }; + let _ = sender.send((client, outcome)); + }); + + let (client, outcome) = + receiver.recv_timeout(LOGIN_TIMEOUT).map_err(|_| { + CliError::connection(format!( + "{address} accepted the connection but did not answer the \ + login within {}s", + LOGIN_TIMEOUT.as_secs() + )) + })?; + + match outcome { + Ok(true) => Ok(client), + Ok(false) => Err(CliError::connection( + "login rejected by the server (wrong password, or the name is \ + taken)", + )), + Err(e) => Err(CliError::connection(format!("login failed: {e}"))), + } +} + +/// Ask for the room list and wait for the server's reply. +/// +/// Events already queued are discarded first: servers push a room list at +/// login, and counting that one would answer a question we had not asked yet. +pub fn await_room_list( + client: &Client, + timeout: Duration, + poll: Duration, +) -> Option> { + let _ = client.take_room_events(); + client.request_room_list().ok()?; + + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let listed = + client.take_room_events().into_iter().find_map( + |event| match event { + RoomEvent::List(rooms) => Some(rooms), + _ => None, + }, + ); + if listed.is_some() { + return listed; + } + std::thread::sleep(poll); + } + None +} + +/// Wait until the server has answered something we asked for after `send`. +/// +/// The server connection is a single ordered stream served by one actor, so a +/// reply to a request queued *after* an earlier message proves that earlier +/// message reached the socket. The room list is the cheapest such request and +/// every server answers it, which lets `message send` and `room say` report +/// delivery honestly instead of sleeping and hoping. +pub fn confirm_flush(client: &Client, timeout: Duration) -> bool { + await_room_list(client, timeout, Duration::from_millis(50)).is_some() +} + +pub fn run(ctx: &Ctx, command: Commands) -> CliResult { + match command { + Commands::Search(args) => transfer::search(ctx, &args), + Commands::Download(args) => transfer::download(ctx, &args), + Commands::Get(args) => transfer::get(ctx, &args), + Commands::Browse(args) => social::browse(ctx, &args), + Commands::Room(RoomCommand::List { timeout }) => { + social::room_list(ctx, Duration::from_secs(timeout)) + } + Commands::Room(RoomCommand::Say { room, message }) => { + social::room_say(ctx, &room, &message) + } + Commands::Room(RoomCommand::Listen { + room, + duration, + follow, + }) => social::room_listen(ctx, &room, listen_span(duration, follow)), + Commands::Message(MessageCommand::Send { user, message }) => { + social::message_send(ctx, &user, &message) + } + Commands::Message(MessageCommand::Read { duration, follow }) => { + social::message_read(ctx, listen_span(duration, follow)) + } + // Handled before credentials are required; see main.rs. + Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port), + } +} + +/// `--follow` means "until interrupted": a span nothing will outlive. +fn listen_span(duration: u64, follow: bool) -> Option { + (!follow).then(|| Duration::from_secs(duration)) +} + +/// Probe automatic port mapping. A router that does not answer is a negative +/// answer, not a crash, so it exits 4 the way `grep` exits 1 on no match. +pub fn portmap(out: &Out, port: u16) -> CliResult { + out.status(&format!("probing automatic port mapping for TCP {port}…")); + let probe = port_mapping::probe(port); + out.emit(&PortmapRecord { + ok: probe.ok, + backend: probe.backend.to_string(), + external: probe.external.clone(), + port, + }); + out.status(&probe.advice); + if probe.ok { + Ok(()) + } else { + // The advice is already on stderr; the error line only has to say + // what the exit code means. + Err(CliError::new( + Exit::NoResults, + format!("no router mapped TCP {port}"), + )) + } +} + +#[cfg(test)] +mod tests { + use super::listen_span; + use std::time::Duration; + + #[test] + fn follow_listens_without_a_deadline() { + assert_eq!(listen_span(30, true), None); + assert_eq!(listen_span(30, false), Some(Duration::from_secs(30))); + } +} diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs new file mode 100644 index 0000000..531164a --- /dev/null +++ b/soulseek-rs/src/commands/social.rs @@ -0,0 +1,297 @@ +//! Talking to people and looking at what they share: `browse`, `room`, and +//! `message`. + +use super::{Ctx, Session, confirm_flush}; +use crate::cli::BrowseArgs; +use crate::output::{ + BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord, + RoomEventRecord, RoomRecord, +}; +use soulseek_rs::types::RoomEvent; +use soulseek_rs::{Client, SharedDirectory}; +use std::time::{Duration, Instant}; + +/// How long to wait for the server to acknowledge something we sent. +const FLUSH_TIMEOUT: Duration = Duration::from_secs(10); +/// Polling cadence for the request/poll parts of the client API. +const POLL: Duration = Duration::from_millis(100); + +/// Join a listing's directory and file name into the remote path that +/// `download` expects — the protocol reports the two separately. +#[must_use] +pub fn full_path(directory: &str, basename: &str) -> String { + let directory = directory.trim_end_matches('\\'); + if directory.is_empty() { + basename.to_string() + } else { + format!("{directory}\\{basename}") + } +} + +/// Ask a peer for its share listing and wait for the answer. +pub fn fetch_listing( + client: &Client, + user: &str, + timeout: Duration, +) -> CliResult> { + client.browse_user(user).map_err(|e| { + CliError::connection(format!("cannot ask {user} for a listing: {e}")) + })?; + + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Some(directories) = client.take_browse_result(user) { + return Ok(directories); + } + std::thread::sleep(POLL); + } + Err(CliError::timeout(format!( + "{user} did not send a file list within {}s", + timeout.as_secs() + ))) +} + +pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult { + let session = Session::open(ctx)?; + ctx.out + .status(&format!("requesting {}'s shared files…", args.user)); + + let directories = fetch_listing( + &session.client, + &args.user, + Duration::from_secs(args.timeout), + )?; + + let mut files = 0usize; + for directory in &directories { + for (basename, size) in &directory.files { + files += 1; + ctx.out.emit(&BrowseRecord { + user: args.user.clone(), + directory: directory.name.clone(), + path: full_path(&directory.name, basename), + size: *size, + }); + } + } + + if files == 0 { + return Err(CliError::no_results(format!( + "{} shares no files", + args.user + ))); + } + Ok(()) +} + +pub fn room_list(ctx: &Ctx, timeout: Duration) -> CliResult { + let session = Session::open(ctx)?; + ctx.out.status("fetching the room list…"); + + let Some(mut rooms) = + super::await_room_list(&session.client, timeout, POLL) + else { + return Err(CliError::timeout(format!( + "the server sent no room list within {}s", + timeout.as_secs() + ))); + }; + if rooms.is_empty() { + return Err(CliError::no_results("the server lists no public rooms")); + } + + rooms.sort_by(|a, b| { + b.user_count + .cmp(&a.user_count) + .then_with(|| a.name.cmp(&b.name)) + }); + for room in rooms { + ctx.out.emit(&RoomRecord { + room: room.name, + users: room.user_count, + }); + } + Ok(()) +} + +pub fn room_say(ctx: &Ctx, room: &str, message: &str) -> CliResult { + let session = Session::open(ctx)?; + session.client.join_room(room).map_err(|e| { + CliError::connection(format!("cannot join {room}: {e}")) + })?; + session.client.say_in_room(room, message).map_err(|e| { + CliError::connection(format!("cannot post to {room}: {e}")) + })?; + + if confirm_flush(&session.client, FLUSH_TIMEOUT) { + ctx.out.status(&format!("posted to {room}")); + return Ok(()); + } + Err(CliError::timeout(format!( + "the server did not acknowledge the message to {room}" + ))) +} + +pub fn room_listen(ctx: &Ctx, room: &str, span: Option) -> CliResult { + let session = Session::open(ctx)?; + session.client.join_room(room).map_err(|e| { + CliError::connection(format!("cannot join {room}: {e}")) + })?; + + stream(&ctx.out, &format!("listening to {room}"), span, || { + for event in session.client.take_room_events() { + if let Some(record) = room_event_record(room, &event) { + ctx.out.emit(&record); + } + } + }); + let _ = session.client.leave_room(room); + Ok(()) +} + +/// Drain `next` until `span` elapses, or forever when it is `None` +/// (`--follow`, ended by the operator). +fn stream( + out: &Out, + what: &str, + span: Option, + mut next: impl FnMut(), +) { + out.status(&match span { + Some(span) => format!("{what} for {}s…", span.as_secs()), + None => format!("{what} until interrupted…"), + }); + let deadline = span.map(|span| Instant::now() + span); + while deadline.is_none_or(|deadline| Instant::now() < deadline) { + next(); + std::thread::sleep(POLL); + } +} + +/// Turn a room event into a record, ignoring events for other rooms and the +/// bookkeeping ones a listener has no use for. +fn room_event_record(room: &str, event: &RoomEvent) -> Option { + match event { + RoomEvent::Message { + room: name, + username, + message, + } if name == room => Some(RoomEventRecord { + kind: "message", + room: name.clone(), + user: username.clone(), + message: message.clone(), + }), + RoomEvent::UserJoined { + room: name, + username, + } if name == room => Some(RoomEventRecord { + kind: "join", + room: name.clone(), + user: username.clone(), + message: String::new(), + }), + RoomEvent::UserLeft { + room: name, + username, + } if name == room => Some(RoomEventRecord { + kind: "leave", + room: name.clone(), + user: username.clone(), + message: String::new(), + }), + _ => None, + } +} + +pub fn message_send(ctx: &Ctx, user: &str, message: &str) -> CliResult { + let session = Session::open(ctx)?; + session + .client + .send_private_message(user, message) + .map_err(|e| { + CliError::connection(format!("cannot send to {user}: {e}")) + })?; + + if confirm_flush(&session.client, FLUSH_TIMEOUT) { + ctx.out.status(&format!("sent to {user}")); + return Ok(()); + } + Err(CliError::timeout(format!( + "the server did not acknowledge the message to {user}" + ))) +} + +pub fn message_read(ctx: &Ctx, span: Option) -> CliResult { + let session = Session::open(ctx)?; + + stream(&ctx.out, "reading private messages", span, || { + for message in session.client.take_private_messages() { + ctx.out.emit(&PrivateMessageRecord { + timestamp: message.timestamp(), + user: message.username().to_string(), + message: message.message().to_string(), + }); + } + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_path_joins_a_directory_and_basename_with_a_backslash() { + assert_eq!(full_path("@@abc\\Music", "a.mp3"), "@@abc\\Music\\a.mp3"); + } + + #[test] + fn full_path_does_not_double_a_trailing_separator() { + assert_eq!(full_path("@@abc\\Music\\", "a.mp3"), "@@abc\\Music\\a.mp3"); + } + + #[test] + fn full_path_of_a_rootless_listing_is_the_basename() { + assert_eq!(full_path("", "a.mp3"), "a.mp3"); + } + + #[test] + fn only_events_for_the_listened_room_become_records() { + let mine = RoomEvent::Message { + room: "lobby".into(), + username: "bob".into(), + message: "hi".into(), + }; + let elsewhere = RoomEvent::Message { + room: "other".into(), + username: "bob".into(), + message: "hi".into(), + }; + assert!(room_event_record("lobby", &mine).is_some()); + assert!(room_event_record("lobby", &elsewhere).is_none()); + } + + #[test] + fn joins_and_leaves_are_labelled() { + let joined = RoomEvent::UserJoined { + room: "lobby".into(), + username: "sue".into(), + }; + let left = RoomEvent::UserLeft { + room: "lobby".into(), + username: "sue".into(), + }; + assert_eq!(room_event_record("lobby", &joined).unwrap().kind, "join"); + assert_eq!(room_event_record("lobby", &left).unwrap().kind, "leave"); + } + + #[test] + fn our_own_join_confirmation_is_not_a_record() { + let joined = RoomEvent::Joined { + room: "lobby".into(), + users: vec!["bob".into()], + }; + assert!(room_event_record("lobby", &joined).is_none()); + } +} diff --git a/soulseek-rs/src/commands/transfer.rs b/soulseek-rs/src/commands/transfer.rs new file mode 100644 index 0000000..82f827f --- /dev/null +++ b/soulseek-rs/src/commands/transfer.rs @@ -0,0 +1,840 @@ +//! Finding files and fetching them: `search`, `download`, and `get`. + +use super::{Ctx, Session, social}; +use crate::cli::{DownloadArgs, GetArgs, Pick, SearchArgs, SortKey}; +use crate::output::{ + CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord, +}; +use soulseek_rs::utils::path::expand_tilde; +use soulseek_rs::{Client, DownloadStatus, SearchResult}; +use std::collections::{HashMap, VecDeque}; +use std::io::BufRead; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{RecvTimeoutError, channel}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Soulseek file attribute codes the network agrees on. +const ATTR_BITRATE: u32 = 0; +const ATTR_DURATION: u32 = 1; + +/// What to keep out of the raw result set. +#[derive(Debug, Clone, Copy, Default)] +pub struct Filter { + pub min_bitrate: Option, + pub free_slots_only: bool, +} + +impl Filter { + const fn keeps(&self, candidate: &SearchRecord) -> bool { + if self.free_slots_only && !candidate.free_slot { + return false; + } + match (self.min_bitrate, candidate.bitrate) { + (Some(minimum), Some(bitrate)) => bitrate >= minimum, + // An unknown bitrate cannot satisfy a bitrate floor. + (Some(_), None) => false, + (None, _) => true, + } + } +} + +/// Flatten per-peer results into individual files, dropping what `filter` +/// rejects. +#[must_use] +pub fn candidates( + results: &[SearchResult], + filter: &Filter, +) -> Vec { + let mut out = Vec::new(); + for result in results { + for file in &result.files { + let candidate = SearchRecord { + user: result.username.clone(), + path: file.name.clone(), + size: file.size, + bitrate: file.attribs.get(&ATTR_BITRATE).copied(), + duration: file.attribs.get(&ATTR_DURATION).copied(), + free_slot: result.slots > 0, + speed: result.speed, + }; + if filter.keeps(&candidate) { + out.push(candidate); + } + } + } + out +} + +/// Order results. Every mode ends with the same user/path tiebreak so the +/// output of two identical runs is identical. +pub fn rank(items: &mut [SearchRecord], sort: SortKey) { + items.sort_by(|a, b| { + let primary = match sort { + SortKey::Best => b + .free_slot + .cmp(&a.free_slot) + .then_with(|| { + b.bitrate.unwrap_or(0).cmp(&a.bitrate.unwrap_or(0)) + }) + .then_with(|| b.speed.cmp(&a.speed)) + .then_with(|| b.size.cmp(&a.size)), + SortKey::Size => b.size.cmp(&a.size), + SortKey::Bitrate => { + b.bitrate.unwrap_or(0).cmp(&a.bitrate.unwrap_or(0)) + } + SortKey::Speed => b.speed.cmp(&a.speed), + }; + primary + .then_with(|| a.user.cmp(&b.user)) + .then_with(|| a.path.cmp(&b.path)) + }); +} + +/// Where a remote file lands locally — the same rule the library applies, so +/// the path we report is the path that gets written. +#[must_use] +pub fn local_path(download_dir: &str, remote_path: &str) -> PathBuf { + let name = remote_path + .split(['/', '\\']) + .next_back() + .unwrap_or(remote_path); + expand_tilde(download_dir).join(name) +} + +/// One file to fetch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub user: String, + pub path: String, + pub size: u64, +} + +impl From<&SearchRecord> for Request { + fn from(candidate: &SearchRecord) -> Self { + Self { + user: candidate.user.clone(), + path: candidate.path.clone(), + size: candidate.size, + } + } +} + +/// Filter, rank, and cap a raw result set, failing when nothing survives. +fn ranked( + results: &[SearchResult], + filter: &Filter, + sort: SortKey, + limit: usize, + query: &str, +) -> CliResult> { + let mut found = candidates(results, filter); + rank(&mut found, sort); + if limit > 0 { + found.truncate(limit); + } + if found.is_empty() { + return Err(CliError::no_results(format!("no results for '{query}'"))); + } + Ok(found) +} + +pub fn search(ctx: &Ctx, args: &SearchArgs) -> CliResult { + let session = Session::open(ctx)?; + let results = collect(ctx, &session.client, &args.query, false)?; + let found = ranked( + &results, + &Filter { + min_bitrate: args.min_bitrate, + free_slots_only: args.free_slots, + }, + args.sort, + args.limit, + &args.query, + )?; + + for candidate in &found { + ctx.out.emit(candidate); + } + Ok(()) +} + +pub fn download(ctx: &Ctx, args: &DownloadArgs) -> CliResult { + let requests = if args.stdin { + let stdin = std::io::stdin(); + read_requests(&mut stdin.lock(), &ctx.out)? + } else { + let (Some(user), Some(path)) = (args.user.clone(), args.path.clone()) + else { + return Err(CliError::usage( + "download needs USER and PATH, or --stdin", + )); + }; + vec![Request { + user, + path, + size: args.size.unwrap_or(0), + }] + }; + + if requests.is_empty() { + return Err(CliError::no_results("no files to download")); + } + + ensure_download_dir(&ctx.download_dir)?; + let session = Session::open(ctx)?; + let requests = resolve_sizes(ctx, &session.client, requests)?; + download_all( + ctx, + &session.client, + requests, + Duration::from_secs(args.timeout), + ) +} + +pub fn get(ctx: &Ctx, args: &GetArgs) -> CliResult { + ensure_download_dir(&ctx.download_dir)?; + let session = Session::open(ctx)?; + let results = + collect(ctx, &session.client, &args.query, args.pick == Pick::First)?; + + let limit = match args.pick { + Pick::Best | Pick::First => 1, + Pick::All => args.limit, + }; + let found = ranked( + &results, + &Filter { + min_bitrate: args.min_bitrate, + free_slots_only: args.free_slots, + }, + SortKey::Best, + limit, + &args.query, + )?; + + download_all( + ctx, + &session.client, + found.iter().map(Request::from).collect(), + Duration::from_secs(args.timeout), + ) +} + +/// Run a search and hand back the raw per-peer results. +/// +/// `stop_early` returns as soon as anything arrives, which is what +/// `get --pick first` wants; otherwise the search runs its full span because +/// late responders are often the better sources. +fn collect( + ctx: &Ctx, + client: &Arc, + query: &str, + stop_early: bool, +) -> CliResult> { + let timeout = ctx.search_timeout; + ctx.out.status(&format!( + "searching for '{query}' ({}s)…", + timeout.as_secs() + )); + + if !stop_early { + return client + .search(query, timeout) + .map_err(|e| CliError::connection(format!("search failed: {e}"))); + } + + let cancel = Arc::new(AtomicBool::new(false)); + let worker = { + let client = Arc::clone(client); + let query = query.to_string(); + let cancel = Arc::clone(&cancel); + std::thread::spawn(move || { + client.search_with_cancel(&query, timeout, Some(cancel)) + }) + }; + + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if client.get_search_results_count(query) > 0 { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + cancel.store(true, Ordering::Relaxed); + let _ = worker.join(); + Ok(client.get_search_results(query)) +} + +/// Drop requests that would collide with one already queued. +/// +/// Two collisions are possible and both lose data silently: the library keys +/// downloads by a hash of the remote path, so the same path from two peers +/// shadows itself, and two different paths sharing a basename would be written +/// to the same local file. Keep the first (best-ranked) of each and say what +/// was dropped rather than transferring less than asked. +fn dedupe( + requests: impl IntoIterator, + download_dir: &str, + out: &Out, +) -> Vec { + let mut paths: HashMap = HashMap::new(); + let mut targets: HashMap = HashMap::new(); + let mut kept = Vec::new(); + for request in requests { + if let Some(user) = paths.get(&request.path) { + out.warn(&format!( + "skipping {} from {}: same remote path already queued from \ + {user}", + request.path, request.user + )); + continue; + } + let target = local_path(download_dir, &request.path); + if let Some(other) = targets.get(&target) { + out.warn(&format!( + "skipping {} from {}: would overwrite the file already queued \ + from {other}", + request.path, request.user + )); + continue; + } + paths.insert(request.path.clone(), request.user.clone()); + targets.insert(target, request.user.clone()); + kept.push(request); + } + kept +} + +/// A transfer whose size we do not know would be finalized against zero bytes +/// and saved empty, so look it up in the peer's share listing first. +fn resolve_sizes( + ctx: &Ctx, + client: &Client, + requests: Vec, +) -> CliResult> { + if requests.iter().all(|request| request.size > 0) { + return Ok(requests); + } + + let mut sizes: HashMap> = HashMap::new(); + let mut resolved = Vec::with_capacity(requests.len()); + for mut request in requests { + if request.size > 0 { + resolved.push(request); + continue; + } + if !sizes.contains_key(&request.user) { + ctx.out.status(&format!( + "looking up the size of {} in {}'s shares…", + request.path, request.user + )); + let listing = social::fetch_listing( + client, + &request.user, + Duration::from_secs(20), + )?; + sizes.insert( + request.user.clone(), + listing + .iter() + .flat_map(|directory| { + directory.files.iter().map(|(basename, size)| { + ( + social::full_path(&directory.name, basename), + *size, + ) + }) + }) + .collect(), + ); + } + request.size = sizes + .get(&request.user) + .and_then(|files| files.get(&request.path)) + .copied() + .ok_or_else(|| { + CliError::no_results(format!( + "{} does not share '{}' — pass --size to download it \ + anyway", + request.user, request.path + )) + })?; + resolved.push(request); + } + Ok(resolved) +} + +fn ensure_download_dir(download_dir: &str) -> CliResult { + let path = expand_tilde(download_dir); + std::fs::create_dir_all(&path).map_err(|e| { + CliError::usage(format!( + "cannot use download directory {}: {e}", + path.display() + )) + }) +} + +/// Fetch every request, at most `max_concurrent_downloads` at a time, emitting +/// each result as it lands. Any failure fails the command. +fn download_all( + ctx: &Ctx, + client: &Arc, + requests: Vec, + timeout: Duration, +) -> CliResult { + let requests = dedupe(requests, &ctx.download_dir, &ctx.out); + let workers = ctx.max_concurrent_downloads.clamp(1, requests.len().max(1)); + let queue = Mutex::new(VecDeque::from(requests)); + let (sender, receiver) = channel(); + + let mut failures = std::thread::scope(|scope| { + for _ in 0..workers { + let sender = sender.clone(); + let queue = &queue; + scope.spawn(move || { + while let Some(request) = + queue.lock().ok().and_then(|mut q| q.pop_front()) + { + let outcome = download_one( + client, + &ctx.out, + &request, + &ctx.download_dir, + timeout, + ); + if sender.send((request, outcome)).is_err() { + break; + } + } + }); + } + drop(sender); + + let mut failures = Vec::new(); + for (request, outcome) in receiver { + match outcome { + Ok(record) => ctx.out.emit(&record), + Err(error) => { + ctx.out.error(&format!("{}: {error}", request.path)); + failures.push(error); + } + } + } + failures + }); + + if failures.is_empty() { + return Ok(()); + } + if failures.len() == 1 { + return Err(failures.remove(0)); + } + let n = failures.len(); + if failures.iter().all(|f| f.exit == Exit::Timeout) { + return Err(CliError::new( + Exit::Timeout, + format!("{n} downloads timed out"), + )); + } + Err(CliError::transfer(format!("{n} downloads failed"))) +} + +/// Queue one transfer and watch it to a verdict. The library reports failure +/// on the status channel rather than through the call that starts the +/// transfer, so the channel is the only thing worth believing. +fn download_one( + client: &Client, + out: &Out, + request: &Request, + download_dir: &str, + timeout: Duration, +) -> CliResult { + // Clear any earlier attempt: entries are keyed by a hash of the remote + // path, so a stale one would shadow this transfer. + let _ = client.remove_download(&request.user, &request.path); + + let (_download, updates) = client + .download( + request.path.clone(), + request.user.clone(), + request.size, + download_dir.to_string(), + ) + .map_err(|e| CliError::transfer(format!("cannot start: {e}")))?; + + out.status(&format!( + "requesting {} from {} ({} bytes)…", + request.path, request.user, request.size + )); + + let deadline = Instant::now() + timeout; + let mut last_report = Instant::now(); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let _ = client.remove_download(&request.user, &request.path); + return Err(CliError::timeout(format!( + "gave up after {}s", + timeout.as_secs() + ))); + } + match updates.recv_timeout(remaining.min(Duration::from_millis(250))) { + Ok(DownloadStatus::Completed) => { + let file = local_path(download_dir, &request.path); + return Ok(DownloadRecord { + user: request.user.clone(), + path: request.path.clone(), + size: request.size, + file: file.display().to_string(), + }); + } + Ok(DownloadStatus::Failed(reason)) => { + let _ = client.remove_download(&request.user, &request.path); + return Err(CliError::transfer( + reason.unwrap_or_else(|| "the transfer failed".to_string()), + )); + } + Ok(DownloadStatus::Queued) => { + out.status(&format!( + "queued behind {}'s uploads", + request.user + )); + } + Ok(DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + }) => { + if last_report.elapsed() >= Duration::from_secs(1) { + last_report = Instant::now(); + out.status(&format!( + "{}: {bytes_downloaded}/{total_bytes} bytes ({:.0} KB/s)", + request.path, + speed_bytes_per_sec / 1024.0 + )); + } + } + Ok(DownloadStatus::Paused { .. } | DownloadStatus::TimedOut) + | Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + let _ = client.remove_download(&request.user, &request.path); + return Err(CliError::transfer( + "the transfer ended without reporting a result", + )); + } + } + } +} + +/// Parse `search`/`browse` output back into download requests. +/// +/// Both shapes this program emits are accepted: a JSON object per line, or +/// tab-separated text whose first field is the user and last field is the +/// remote path, with the size in between when there is one. +fn read_requests( + input: &mut impl BufRead, + out: &Out, +) -> CliResult> { + let mut requests = Vec::new(); + for (number, line) in input.lines().enumerate() { + let line = line + .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; + match parse_request(&line) { + Ok(Some(request)) => requests.push(request), + Ok(None) => {} + Err(reason) => { + out.warn(&format!("stdin line {}: {reason}", number + 1)); + } + } + } + Ok(requests) +} + +/// `Ok(None)` for a blank line, `Err` for something unusable. +fn parse_request(line: &str) -> Result, String> { + let line = line.trim_end_matches(['\r', '\n']); + if line.trim().is_empty() { + return Ok(None); + } + + let (user, path, size) = if line.trim_start().starts_with('{') { + let record: JsonRequest = serde_json::from_str(line.trim()) + .map_err(|e| format!("invalid JSON record: {e}"))?; + (record.user, record.path, record.size) + } else { + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() < 2 { + return Err( + "expected tab-separated USER…PATH or a JSON record".to_string() + ); + } + let size = if fields.len() >= 3 { + fields[1].trim().parse().unwrap_or(0) + } else { + 0 + }; + ( + fields[0].trim().to_string(), + fields[fields.len() - 1].trim().to_string(), + size, + ) + }; + + if user.is_empty() || path.is_empty() { + return Err("user and path must not be empty".to_string()); + } + Ok(Some(Request { user, path, size })) +} + +/// The subset of a `search`/`browse` JSON record a download needs. Extra +/// fields are ignored, so a record enriched by `jq` still parses. +#[derive(serde::Deserialize)] +struct JsonRequest { + user: String, + path: String, + #[serde(default)] + size: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use soulseek_rs::File; + use std::collections::HashMap; + + fn file(name: &str, size: u64, bitrate: Option) -> File { + let mut attribs = HashMap::new(); + if let Some(bitrate) = bitrate { + attribs.insert(ATTR_BITRATE, bitrate); + attribs.insert(ATTR_DURATION, 200); + } + File { + username: String::new(), + name: name.to_string(), + size, + attribs, + } + } + + fn result( + user: &str, + slots: u8, + speed: u32, + files: Vec, + ) -> SearchResult { + SearchResult { + token: 1, + files, + slots, + speed, + username: user.to_string(), + } + } + + fn sample() -> Vec { + vec![ + result("slow", 1, 100, vec![file("a\\low.mp3", 10, Some(128))]), + result("busy", 0, 900, vec![file("b\\best.mp3", 90, Some(320))]), + result( + "fast", + 1, + 500, + vec![ + file("c\\good.mp3", 50, Some(256)), + file("c\\unknown.mp3", 70, None), + ], + ), + ] + } + + #[test] + fn candidates_flatten_every_file_of_every_peer() { + let found = candidates(&sample(), &Filter::default()); + assert_eq!(found.len(), 4); + assert!(found.iter().any(|c| c.user == "fast" && c.size == 70)); + } + + #[test] + fn candidates_carry_bitrate_duration_and_slot_state() { + let found = candidates(&sample(), &Filter::default()); + let best = found.iter().find(|c| c.path == "b\\best.mp3").unwrap(); + assert_eq!(best.bitrate, Some(320)); + assert_eq!(best.duration, Some(200)); + assert!(!best.free_slot); + assert_eq!(best.speed, 900); + } + + #[test] + fn min_bitrate_drops_lower_and_unknown_bitrates() { + let filter = Filter { + min_bitrate: Some(256), + free_slots_only: false, + }; + let found = candidates(&sample(), &filter); + let paths: Vec<&str> = found.iter().map(|c| c.path.as_str()).collect(); + assert_eq!(paths, ["b\\best.mp3", "c\\good.mp3"]); + } + + #[test] + fn free_slots_only_keeps_peers_advertising_a_slot() { + let filter = Filter { + min_bitrate: None, + free_slots_only: true, + }; + let found = candidates(&sample(), &filter); + assert!(found.iter().all(|c| c.free_slot)); + assert!(found.iter().all(|c| c.user != "busy")); + } + + #[test] + fn best_prefers_a_free_slot_over_a_higher_bitrate() { + let mut found = candidates(&sample(), &Filter::default()); + rank(&mut found, SortKey::Best); + assert_eq!(found[0].path, "c\\good.mp3"); + assert_eq!(found[0].user, "fast"); + } + + #[test] + fn sort_modes_lead_with_their_own_key() { + let mut found = candidates(&sample(), &Filter::default()); + rank(&mut found, SortKey::Size); + assert_eq!(found[0].size, 90); + rank(&mut found, SortKey::Bitrate); + assert_eq!(found[0].bitrate, Some(320)); + rank(&mut found, SortKey::Speed); + assert_eq!(found[0].speed, 900); + } + + #[test] + fn ranking_is_deterministic_for_equivalent_results() { + let tie = vec![ + result("zed", 1, 1, vec![file("x.mp3", 1, Some(128))]), + result("amy", 1, 1, vec![file("x.mp3", 1, Some(128))]), + ]; + let mut found = candidates(&tie, &Filter::default()); + rank(&mut found, SortKey::Best); + assert_eq!(found[0].user, "amy"); + } + + #[test] + fn local_path_uses_the_last_segment_of_a_windows_style_path() { + assert_eq!( + local_path("/music", "@@abc\\Albums\\01 - Track.mp3"), + PathBuf::from("/music/01 - Track.mp3") + ); + assert_eq!( + local_path("/music", "plain.mp3"), + PathBuf::from("/music/plain.mp3") + ); + assert_eq!( + local_path("/music", "unix/style/file.mp3"), + PathBuf::from("/music/file.mp3") + ); + } + + #[test] + fn stdin_accepts_the_json_records_search_emits() { + let request = parse_request( + r#"{"user":"bob","path":"a\\b.mp3","size":42,"bitrate":320}"#, + ) + .unwrap() + .unwrap(); + assert_eq!(request.user, "bob"); + assert_eq!(request.path, "a\\b.mp3"); + assert_eq!(request.size, 42); + } + + #[test] + fn stdin_accepts_the_text_records_search_emits() { + let request = parse_request("bob\t4096\t320\t@@x\\track.mp3") + .unwrap() + .unwrap(); + assert_eq!(request.user, "bob"); + assert_eq!(request.size, 4096); + assert_eq!(request.path, "@@x\\track.mp3"); + } + + #[test] + fn stdin_accepts_a_bare_user_and_path_pair() { + let request = parse_request("bob\t@@x\\track.mp3").unwrap().unwrap(); + assert_eq!(request.user, "bob"); + assert_eq!(request.path, "@@x\\track.mp3"); + assert_eq!(request.size, 0); + } + + #[test] + fn stdin_skips_blank_lines_and_rejects_junk() { + assert_eq!(parse_request(" ").unwrap(), None); + assert!(parse_request("nonsense").is_err()); + assert!(parse_request("{\"path\":\"a\"}").is_err()); + assert!(parse_request("\tpath-only").is_err()); + } + + #[test] + fn a_bad_line_does_not_discard_the_good_ones() { + let input = "bob\t10\t320\ta.mp3\nnonsense\n\nsue\tb.mp3\n"; + let out = Out::new(false, true); + let requests = read_requests(&mut input.as_bytes(), &out).unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].user, "sue"); + } + + fn request(user: &str, path: &str) -> Request { + Request { + user: user.into(), + path: path.into(), + size: 1, + } + } + + #[test] + fn duplicate_remote_paths_are_dropped_because_the_store_keys_by_path() { + let out = Out::new(false, true); + let kept = dedupe( + vec![ + request("a", "same.mp3"), + request("b", "same.mp3"), + request("c", "other.mp3"), + ], + "/music", + &out, + ); + assert_eq!(kept.len(), 2); + assert_eq!(kept[0].user, "a"); + assert_eq!(kept[1].path, "other.mp3"); + } + + #[test] + fn requests_that_would_overwrite_the_same_local_file_are_dropped() { + // Different remote paths, same basename: both would be written to + // /music/track.mp3 and the slower transfer would clobber the other. + let out = Out::new(false, true); + let kept = dedupe( + vec![ + request("a", "@@one\\album\\track.mp3"), + request("b", "@@two\\other\\track.mp3"), + ], + "/music", + &out, + ); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].user, "a"); + } + + #[test] + fn stdin_rejects_a_json_record_with_empty_fields() { + assert!(parse_request(r#"{"user":"","path":"a.mp3"}"#).is_err()); + assert!(parse_request(r#"{"user":"bob","path":""}"#).is_err()); + } + + #[test] + fn stdin_ignores_extra_json_fields_a_filter_may_have_added() { + let request = + parse_request(r#"{"user":"bob","path":"a.mp3","size":7,"tag":1}"#) + .unwrap() + .unwrap(); + assert_eq!(request.size, 7); + } +} diff --git a/soulseek-rs/src/config.rs b/soulseek-rs/src/config.rs deleted file mode 100644 index 28f720b..0000000 --- a/soulseek-rs/src/config.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct SearchConfig { - pub username: String, - pub password: String, - pub server_host: String, - pub server_port: u16, - pub enable_listener: bool, - pub listener_port: u16, - pub query: String, - pub timeout: u64, - pub download_dir: String, - pub verbose: u8, - pub max_concurrent_downloads: usize, - pub shared_directories: Vec, -} diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 6272ff2..3042f15 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -1,149 +1,177 @@ mod cli; -mod config; +mod commands; mod directories; mod models; +mod output; mod persist; mod port_mapping; mod ui; use clap::Parser; use cli::{Cli, Commands, parse_server_address}; -use color_eyre::Result; -use config::SearchConfig; -use soulseek_rs::{Client, ClientSettings, PeerAddress}; -use std::{ - env, - sync::{Arc, atomic::AtomicBool}, - time::Duration, -}; -use ui::{FileSelector, launch_main_tui, show_multi_download_progress}; +use commands::Ctx; +use output::{CliError, CliResult, Exit, Out}; +use soulseek_rs::{ClientSettings, PeerAddress}; +use std::io::{BufRead, IsTerminal}; +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::Arc; +use std::time::Duration; +use std::{env, io}; +use ui::launch_main_tui; -fn main() -> Result<()> { +fn main() -> ExitCode { dotenv::dotenv().ok(); - color_eyre::install()?; + let _ = color_eyre::install(); let cli = Cli::parse(); - init_logging(&cli); - // Layer CLI/env values over config.toml over built-in defaults. - let config_path = persist::paths::config_file(); + let out = Out::new(cli.json, cli.quiet); + match run(cli, &out) { + Ok(()) => Exit::Ok.into(), + Err(error) => { + out.error(&error.message); + error.exit.into() + } + } +} + +fn run(mut cli: Cli, out: &Out) -> CliResult { + let config_path = config_path(&cli); let file_config = match &config_path { - Some(path) => persist::config::FileConfig::load(path)?, + Some(path) => persist::config::FileConfig::load(path) + .map_err(|e| CliError::usage(e.to_string()))?, None => persist::config::FileConfig::default(), }; let resolved = persist::config::resolve(&cli, &file_config); - // `portmap` is a local network diagnostic; it needs no server credentials, - // so handle it before requiring a username/password. - if matches!(cli.command, Some(Commands::Portmap)) { - println!( - "Testing automatic port mapping for TCP {}…", - resolved.listener_port - ); - println!("{}", port_mapping::diagnose(resolved.listener_port)); - return Ok(()); - } - - // The interactive TUI handles missing credentials itself with a - // login/registration screen; only the one-shot subcommands hard-require - // them up front. - if cli.command.is_none() { + let Some(command) = cli.command.take() else { return run_default_tui(&cli, &resolved, config_path, &file_config); + }; + + // A local network probe needs no account, so it runs before credentials + // are demanded. + if matches!(command, Commands::Portmap) { + return commands::portmap(out, resolved.listener_port); } + let ctx = context(&cli, &resolved, out)?; + commands::run(&ctx, command) +} + +/// Which config file to read: an explicit `--config`, the platform default, +/// or none at all under `--no-config`. +fn config_path(cli: &Cli) -> Option { + if cli.no_config { + return None; + } + cli.config.clone().or_else(persist::paths::config_file) +} + +/// Assemble everything a one-shot command needs, demanding the credentials +/// the interactive login screen would otherwise ask for. +fn context( + cli: &Cli, + resolved: &persist::config::Resolved, + out: &Out, +) -> CliResult { let username = resolved.username.clone().ok_or_else(|| { - color_eyre::eyre::eyre!( - "Username required: use --username, set SOULSEEK_USERNAME, or add it to config.toml" + CliError::usage( + "no username: pass --username, set SOULSEEK_USERNAME, or put one \ + in config.toml", ) })?; + let password = password(cli, resolved)?.ok_or_else(|| { + CliError::usage( + "no password: pass --password/--password-stdin, set \ + SOULSEEK_PASSWORD, store one in the OS keychain, or set \ + password_cmd", + ) + })?; + let (host, port) = + parse_server_address(&resolved.server).map_err(CliError::usage)?; - let password = persist::secret::resolve_password( + Ok(Ctx { + out: out.clone(), + settings: ClientSettings { + username, + password, + server_address: PeerAddress::new(host, port), + enable_listen: !resolved.disable_listener, + listen_port: resolved.listener_port, + shared_directories: shared_directories(resolved, out), + }, + download_dir: resolved.download_dir.clone(), + max_concurrent_downloads: resolved.max_concurrent_downloads, + search_timeout: Duration::from_secs(resolved.search_timeout), + }) +} + +/// Resolve the password: stdin beats the flag/env, which beats the keychain, +/// which beats `password_cmd`. +fn password( + cli: &Cli, + resolved: &persist::config::Resolved, +) -> CliResult> { + if cli.password_stdin { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + return Err(CliError::usage("no password on stdin")); + } + return Ok(Some(line.to_string())); + } + Ok(persist::secret::resolve_password( cli.password.as_deref(), resolved.username.as_deref(), resolved.password_cmd.as_deref(), &persist::secret::KeyringStore, - ) - .ok_or_else(|| { - color_eyre::eyre::eyre!( - "Password required: use --password, set SOULSEEK_PASSWORD, or store one in the OS keychain" - ) - })?; - - let (server_host, server_port) = parse_server_address(&resolved.server)?; - - // Resolve the configured shared/upload directories up front; a - // misconfigured one is a warning, not a fatal error. - let shared_directories = - directories::resolve_shared_directories(&resolved.shared_dirs); - - let settings = ClientSettings { - username: username.clone(), - password: password.clone(), - server_address: PeerAddress::new(server_host.clone(), server_port), - enable_listen: !resolved.disable_listener, - listen_port: resolved.listener_port, - shared_directories: shared_directories.clone(), - }; - - match cli.command { - Some(Commands::Search { - query, - timeout, - download_dir, - max_concurrent_downloads, - }) => { - let config = SearchConfig { - username, - password, - server_host, - server_port, - enable_listener: !resolved.disable_listener, - listener_port: resolved.listener_port, - query, - timeout, - download_dir, - verbose: cli.verbose, - max_concurrent_downloads, - shared_directories, - }; - search_and_download(config) - } - Some(Commands::Message { - username: recipient, - message, - }) => send_private_message(&settings, &recipient, &message), - Some(Commands::Browse { username: target }) => { - browse_user(&settings, &target) - } - Some(Commands::Rooms) => list_rooms(&settings), - Some(Commands::Chat { - room, - message, - listen_secs, - }) => chat_room(&settings, &room, message.as_deref(), listen_secs), - // Portmap is handled before the credential check; None returns early - // into run_default_tui above. - Some(Commands::Portmap) | None => unreachable!(), - } + )) } -fn init_logging(cli: &Cli) { - let log_level = match cli.verbose { - 0 => "ERROR", - 1 => "WARN", - 2 => "INFO", - 3 => "DEBUG", - _ => "TRACE", - }; - // SAFETY: Called before any threads are spawned - unsafe { env::set_var("LOG_LEVEL", log_level) }; +/// Validate the configured shares. A bad entry is a warning, not a failure: +/// the client simply shares less. +fn shared_directories( + resolved: &persist::config::Resolved, + out: &Out, +) -> Vec { + let mut valid = Vec::new(); + for dir in &resolved.shared_dirs { + match directories::resolve_shared_directory(Some(dir)) { + Ok(Some(path)) => valid.push(path.display().to_string()), + Ok(None) => {} + Err(e) => out.warn(&format!("ignoring shared directory: {e}")), + } + } + valid +} - if let Some(log_file) = &cli.log_file { - // SAFETY: Called before any threads are spawned - unsafe { - env::set_var("LOG_FILE", log_file.to_string_lossy().to_string()); - }; +/// `-v` raises the level; without it, an explicit `LOG_LEVEL`/`RUST_LOG` from +/// the environment is honoured, and otherwise logging stays at errors only so +/// stderr carries this program's own output and nothing else. +fn init_logging(cli: &Cli) { + let level = match cli.verbose { + 0 => (env::var_os("LOG_LEVEL").is_none() + && env::var_os("RUST_LOG").is_none()) + .then_some("ERROR"), + 1 => Some("WARN"), + 2 => Some("INFO"), + 3 => Some("DEBUG"), + _ => Some("TRACE"), + }; + // SAFETY: called before any threads are spawned. + unsafe { + if let Some(level) = level { + env::set_var("LOG_LEVEL", level); + } + if let Some(log_file) = &cli.log_file { + env::set_var("LOG_FILE", log_file.as_os_str()); + } } } @@ -154,37 +182,41 @@ fn init_logging(cli: &Cli) { fn run_default_tui( cli: &Cli, resolved: &persist::config::Resolved, - config_path: Option, + config_path: Option, file_config: &persist::config::FileConfig, -) -> Result<()> { +) -> CliResult { use ratatui::crossterm::{ event::EnableMouseCapture, execute, terminal::{Clear, ClearType}, }; - let (server_host, server_port) = parse_server_address(&resolved.server)?; + // Without a terminal the TUI would panic on its first draw. A script that + // reached this point wanted a subcommand, so say that instead of crashing. + if !std::io::stdout().is_terminal() { + return Err(CliError::usage( + "the interactive interface needs a terminal; run a subcommand for \ + scripted use (see --help)", + )); + } + + let out = Out::new(false, false); + let (server_host, server_port) = + parse_server_address(&resolved.server).map_err(CliError::usage)?; // Make sure the download folder exists up front: first-run defaults point // download and shared at the same fresh Downloads/Soulseek folder, and // shared-directory validation requires an existing path. if let Err(e) = std::fs::create_dir_all(&resolved.download_dir) { - eprintln!( - "⚠️ Could not create download directory {}: {e}", + out.warn(&format!( + "could not create download directory {}: {e}", resolved.download_dir - ); + )); } - let shared_directories = - directories::resolve_shared_directories(&resolved.shared_dirs); - + let shared_directories = shared_directories(resolved, &out); let secret_store = persist::secret::KeyringStore; - let initial_password = persist::secret::resolve_password( - cli.password.as_deref(), - resolved.username.as_deref(), - resolved.password_cmd.as_deref(), - &secret_store, - ); + let initial_password = password(cli, resolved)?; // Enable logger buffering BEFORE connection to prevent log artifacts soulseek_rs::utils::logger::enable_buffering(); @@ -229,7 +261,7 @@ fn run_default_tui( Err(e) => { ratatui::restore(); soulseek_rs::utils::logger::disable_buffering(); - return Err(e); + return Err(CliError::new(Exit::Failure, e.to_string())); } }; @@ -246,6 +278,7 @@ fn run_default_tui( Duration::from_secs(resolved.search_timeout), store, ) + .map_err(|e| CliError::new(Exit::Failure, e.to_string())) } /// After a successful login, remember the username in config.toml and — when @@ -253,7 +286,7 @@ fn run_default_tui( /// best-effort: failing to persist must not break a working session. fn persist_credentials( outcome: &ui::login::LoginOutcome, - config_path: Option, + config_path: Option, file_config: &persist::config::FileConfig, secret_store: &dyn persist::secret::SecretStore, ) { @@ -272,291 +305,3 @@ fn persist_credentials( eprintln!("⚠️ Could not store password in keychain: {e}"); } } - -fn browse_user(settings: &ClientSettings, target: &str) -> Result<()> { - use std::time::Instant; - - let _port_mapper = settings - .enable_listen - .then(|| port_mapping::PortMapper::spawn(settings.listen_port)); - let mut client = Client::with_settings(settings.clone()); - client - .connect() - .map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?; - if !client - .login() - .map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))? - { - return Err(color_eyre::eyre::eyre!("Login rejected by server")); - } - - client - .browse_user(target) - .map_err(|e| color_eyre::eyre::eyre!("Failed to browse: {}", e))?; - - println!("📂 Requesting shared files from {target}..."); - let deadline = Instant::now() + Duration::from_secs(20); - while Instant::now() < deadline { - if let Some(directories) = client.take_browse_result(target) { - if directories.is_empty() { - println!("({target} shares nothing)"); - } - for directory in directories { - println!("\n{}/", directory.name); - for (name, size) in directory.files { - println!(" {name} ({size} bytes)"); - } - } - return Ok(()); - } - std::thread::sleep(Duration::from_millis(200)); - } - Err(color_eyre::eyre::eyre!( - "Timed out waiting for {target}'s file list" - )) -} - -/// Connect and log in, returning the ready client or a descriptive error. -fn connect_and_login(settings: &ClientSettings) -> Result { - let mut client = Client::with_settings(settings.clone()); - client - .connect() - .map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?; - if !client - .login() - .map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))? - { - return Err(color_eyre::eyre::eyre!("Login rejected by server")); - } - Ok(client) -} - -fn list_rooms(settings: &ClientSettings) -> Result<()> { - use std::time::Instant; - - let client = connect_and_login(settings)?; - client - .request_room_list() - .map_err(|e| color_eyre::eyre::eyre!("Failed to list rooms: {}", e))?; - - println!("📋 Fetching room list..."); - let deadline = Instant::now() + Duration::from_secs(5); - let mut rooms = client.room_list(); - while rooms.is_empty() && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(200)); - rooms = client.room_list(); - } - - rooms.sort_by(|a, b| { - b.user_count - .cmp(&a.user_count) - .then_with(|| a.name.cmp(&b.name)) - }); - if rooms.is_empty() { - println!("(no public rooms reported)"); - } else { - println!("{:>6} room", "users"); - for room in rooms { - println!("{:>6} {}", room.user_count, room.name); - } - } - Ok(()) -} - -fn chat_room( - settings: &ClientSettings, - room: &str, - message: Option<&str>, - listen_secs: u64, -) -> Result<()> { - use soulseek_rs::types::RoomEvent; - use std::time::Instant; - - let client = connect_and_login(settings)?; - client - .join_room(room) - .map_err(|e| color_eyre::eyre::eyre!("Failed to join room: {}", e))?; - - if let Some(text) = message { - client - .say_in_room(room, text) - .map_err(|e| color_eyre::eyre::eyre!("Failed to say: {}", e))?; - // Let the server actor flush before we drop the client. - std::thread::sleep(Duration::from_millis(500)); - println!("💬 {room}: {text}"); - return Ok(()); - } - - println!( - "💬 Joined {room}. Listening for {listen_secs}s (Ctrl-C to quit)..." - ); - let deadline = Instant::now() + Duration::from_secs(listen_secs); - while Instant::now() < deadline { - for event in client.take_room_events() { - match event { - RoomEvent::Message { - room: r, - username, - message, - } if r == room => println!("<{username}> {message}"), - RoomEvent::UserJoined { room: r, username } if r == room => { - println!("→ {username} joined"); - } - RoomEvent::UserLeft { room: r, username } if r == room => { - println!("← {username} left"); - } - _ => {} - } - } - std::thread::sleep(Duration::from_millis(200)); - } - let _ = client.leave_room(room); - Ok(()) -} - -fn send_private_message( - settings: &ClientSettings, - recipient: &str, - message: &str, -) -> Result<()> { - let mut client = Client::with_settings(settings.clone()); - client - .connect() - .map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?; - if !client - .login() - .map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))? - { - return Err(color_eyre::eyre::eyre!("Login rejected by server")); - } - - client - .send_private_message(recipient, message) - .map_err(|e| { - color_eyre::eyre::eyre!("Failed to send message: {}", e) - })?; - - // The send is dispatched asynchronously; give the server actor a moment to - // flush it to the socket before we drop the client and exit. - std::thread::sleep(Duration::from_millis(500)); - - println!("✉️ Message sent to {recipient}"); - Ok(()) -} - -fn search_and_download(config: SearchConfig) -> Result<()> { - if config.verbose > 0 { - println!( - "🔌 Connecting to Soulseek server {}:{}...", - config.server_host, config.server_port - ); - } - - let settings = ClientSettings { - username: config.username.clone(), - password: config.password.clone(), - server_address: PeerAddress::new( - config.server_host.clone(), - config.server_port, - ), - enable_listen: config.enable_listener, - listen_port: config.listener_port, - shared_directories: config.shared_directories.clone(), - }; - - let _port_mapper = settings - .enable_listen - .then(|| port_mapping::PortMapper::spawn(settings.listen_port)); - - let mut client = Client::with_settings(settings); - client - .connect() - .map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?; - client - .login() - .map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?; - - if config.verbose > 0 { - println!("🔍 Searching for: {}", config.query); - } - - // Wrap client in Arc for sharing with FileSelector - let client = Arc::new(client); - - let cancel_flag = Arc::new(AtomicBool::new(false)); - - let search_client = client.clone(); - let search_query = config.query.clone(); - let search_timeout = Duration::from_secs(config.timeout); - let search_cancel = cancel_flag.clone(); - - let _search_handle = std::thread::spawn(move || { - search_client.search_with_cancel( - &search_query, - search_timeout, - Some(search_cancel), - ) - }); - - // Launch FileSelector immediately with live search enabled - let terminal = ratatui::init(); - let mut file_selector = FileSelector::new_with_live_search( - client.clone(), - config.query.clone(), - Duration::from_secs(config.timeout), - cancel_flag.clone(), - ); - let (terminal, selected_indices) = file_selector.run(terminal)?; - - // Cancel search thread - no need to wait for it - cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed); - - let results = client.get_search_results(&config.query); - - if selected_indices.is_empty() { - ratatui::restore(); - println!("❌ No files selected for download"); - return Ok(()); - } - - // Directly map selected indices to file data (skip expensive all_files conversion) - let selected_files: Vec<_> = selected_indices - .iter() - .filter_map(|&idx| { - let mut current = 0; - for result in &results { - let next = current + result.files.len(); - if idx < next { - let file_idx = idx - current; - let file = &result.files[file_idx]; - return Some(( - file.name.clone(), - result.username.clone(), - file.size, - )); - } - current = next; - } - None - }) - .collect(); - - if selected_files.is_empty() { - ratatui::restore(); - println!("❌ No files found in search results"); - return Ok(()); - } - - // Show multi-download progress view immediately (initializes downloads asynchronously) - show_multi_download_progress( - terminal, - client, - selected_files, - config.download_dir, - config.max_concurrent_downloads, - )?; - - println!("\n✨ Download complete!"); - - Ok(()) -} diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs new file mode 100644 index 0000000..2c4d997 --- /dev/null +++ b/soulseek-rs/src/output.rs @@ -0,0 +1,436 @@ +//! Output discipline for the one-shot commands. +//! +//! stdout carries records and nothing else, stderr carries progress, and the +//! process exit code carries the verdict. Records render either as +//! newline-delimited JSON (`--json`) or as tab-separated text, so both `jq` +//! and `cut` are first-class consumers. + +use serde::Serialize; +use std::io::{IsTerminal, Write}; + +/// Documented exit statuses. Anything a wrapper script needs to branch on has +/// its own code; clap already uses 2 for argument errors, so config problems +/// share it. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Exit { + Ok = 0, + /// Something unexpected went wrong (also the panic/TUI bucket). + Failure = 1, + /// Bad arguments, missing credentials, unusable configuration. + Usage = 2, + /// Could not reach the server, or the server rejected the login. + Connection = 3, + /// The operation succeeded but produced nothing. + NoResults = 4, + /// Gave up waiting for a response or a transfer. + Timeout = 5, + /// A transfer started but did not complete. + Transfer = 6, +} + +impl Exit { + #[must_use] + pub const fn code(self) -> u8 { + self as u8 + } +} + +impl From for std::process::ExitCode { + fn from(exit: Exit) -> Self { + Self::from(exit.code()) + } +} + +/// A failure carrying the exit code it should produce. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliError { + pub exit: Exit, + pub message: String, +} + +impl CliError { + pub fn new(exit: Exit, message: impl Into) -> Self { + Self { + exit, + message: message.into(), + } + } + + pub fn usage(message: impl Into) -> Self { + Self::new(Exit::Usage, message) + } + + pub fn connection(message: impl Into) -> Self { + Self::new(Exit::Connection, message) + } + + pub fn timeout(message: impl Into) -> Self { + Self::new(Exit::Timeout, message) + } + + pub fn transfer(message: impl Into) -> Self { + Self::new(Exit::Transfer, message) + } + + pub fn no_results(message: impl Into) -> Self { + Self::new(Exit::NoResults, message) + } +} + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for CliError {} + +pub type CliResult = Result; + +/// One line of machine-readable output. +pub trait Record: Serialize { + /// Tab-separated rendering. Fields must already be free of tabs and + /// newlines — use [`sanitize`]. + fn text(&self) -> String; +} + +/// Strip the field and record separators out of peer-supplied text so one +/// record always occupies exactly one line with stable columns. +#[must_use] +pub fn sanitize(value: &str) -> String { + value.replace(['\t', '\r', '\n'], " ") +} + +/// Where records go and whether progress is shown. +#[derive(Clone)] +pub struct Out { + json: bool, + quiet: bool, + color: bool, +} + +impl Out { + #[must_use] + pub fn new(json: bool, quiet: bool) -> Self { + Self { + json, + quiet, + color: std::env::var_os("NO_COLOR").is_none() + && std::io::stderr().is_terminal(), + } + } + + /// Write one record to stdout. + pub fn emit(&self, record: &R) { + let line = if self.json { + serde_json::to_string(record) + .expect("a record is plain data and always serializes") + } else { + record.text() + }; + Self::write_line(&line); + } + + /// Progress and narration. Suppressed by `--quiet`, never on stdout. + pub fn status(&self, message: &str) { + if !self.quiet { + let _ = writeln!(std::io::stderr(), "{message}"); + } + } + + /// A problem worth reporting that is not fatal. Shown even when quiet, + /// because silently doing less than asked is worse than a stray line. + pub fn warn(&self, message: &str) { + let _ = if self.color { + writeln!(std::io::stderr(), "\x1b[33mwarning:\x1b[0m {message}") + } else { + writeln!(std::io::stderr(), "warning: {message}") + }; + } + + pub fn error(&self, message: &str) { + let _ = if self.color { + writeln!(std::io::stderr(), "\x1b[31merror:\x1b[0m {message}") + } else { + writeln!(std::io::stderr(), "error: {message}") + }; + } + + // a closed downstream (`| head -1`) is a normal end for a + // pipeline, so exit quietly instead of letting println! panic on EPIPE. + fn write_line(line: &str) { + if writeln!(std::io::stdout(), "{line}").is_err() { + std::process::exit(Exit::Ok.code().into()); + } + } +} + +/// One file found by `search`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SearchRecord { + pub user: String, + pub path: String, + pub size: u64, + pub bitrate: Option, + pub duration: Option, + pub free_slot: bool, + pub speed: u32, +} + +impl Record for SearchRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}\t{}", + sanitize(&self.user), + self.size, + self.bitrate + .map_or_else(|| "-".to_string(), |b| b.to_string()), + sanitize(&self.path) + ) + } +} + +/// One file in a peer's share listing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BrowseRecord { + pub user: String, + pub directory: String, + pub path: String, + pub size: u64, +} + +impl Record for BrowseRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + sanitize(&self.user), + self.size, + sanitize(&self.path) + ) + } +} + +/// The outcome of one transfer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DownloadRecord { + pub user: String, + pub path: String, + pub size: u64, + pub file: String, +} + +impl Record for DownloadRecord { + fn text(&self) -> String { + sanitize(&self.file) + } +} + +/// One public chat room. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RoomRecord { + pub room: String, + pub users: u32, +} + +impl Record for RoomRecord { + fn text(&self) -> String { + format!("{}\t{}", self.users, sanitize(&self.room)) + } +} + +/// Something that happened in a room. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RoomEventRecord { + #[serde(rename = "type")] + pub kind: &'static str, + pub room: String, + pub user: String, + pub message: String, +} + +impl Record for RoomEventRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}\t{}", + self.kind, + sanitize(&self.room), + sanitize(&self.user), + sanitize(&self.message) + ) + } +} + +/// One incoming private message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PrivateMessageRecord { + pub timestamp: u32, + pub user: String, + pub message: String, +} + +impl Record for PrivateMessageRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + self.timestamp, + sanitize(&self.user), + sanitize(&self.message) + ) + } +} + +/// The result of probing automatic port mapping. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PortmapRecord { + pub ok: bool, + pub backend: String, + pub external: Option, + pub port: u16, +} + +impl Record for PortmapRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + if self.ok { "ok" } else { "failed" }, + sanitize(&self.backend), + self.external.as_deref().unwrap_or("-") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn search_record() -> SearchRecord { + SearchRecord { + user: "bob".into(), + path: "@@dir\\01 - Track.mp3".into(), + size: 4096, + bitrate: Some(320), + duration: Some(210), + free_slot: true, + speed: 1000, + } + } + + #[test] + fn exit_codes_are_stable() { + assert_eq!(Exit::Ok.code(), 0); + assert_eq!(Exit::Failure.code(), 1); + assert_eq!(Exit::Usage.code(), 2); + assert_eq!(Exit::Connection.code(), 3); + assert_eq!(Exit::NoResults.code(), 4); + assert_eq!(Exit::Timeout.code(), 5); + assert_eq!(Exit::Transfer.code(), 6); + } + + #[test] + fn sanitize_keeps_records_on_one_line_with_stable_columns() { + assert_eq!(sanitize("a\tb\nc\r\nd"), "a b c d"); + assert_eq!( + sanitize("plain (name) [2024].mp3"), + "plain (name) [2024].mp3" + ); + } + + #[test] + fn search_text_is_user_size_bitrate_path() { + let text = search_record().text(); + let fields: Vec<&str> = text.split('\t').collect(); + assert_eq!(fields, ["bob", "4096", "320", "@@dir\\01 - Track.mp3"]); + } + + #[test] + fn unknown_bitrate_renders_as_a_dash() { + let record = SearchRecord { + bitrate: None, + ..search_record() + }; + assert_eq!(record.text().split('\t').nth(2), Some("-")); + } + + #[test] + fn search_json_carries_every_field() { + let json = serde_json::to_value(search_record()).unwrap(); + assert_eq!(json["user"], "bob"); + assert_eq!(json["size"], 4096); + assert_eq!(json["bitrate"], 320); + assert_eq!(json["duration"], 210); + assert_eq!(json["free_slot"], true); + assert_eq!(json["speed"], 1000); + assert_eq!(json["path"], "@@dir\\01 - Track.mp3"); + } + + #[test] + fn a_tab_in_a_filename_cannot_forge_a_column() { + let record = SearchRecord { + path: "evil\tname.mp3".into(), + ..search_record() + }; + assert_eq!(record.text().split('\t').count(), 4); + } + + #[test] + fn browse_text_is_user_size_path() { + let record = BrowseRecord { + user: "bob".into(), + directory: "@@music".into(), + path: "@@music\\a.flac".into(), + size: 12, + }; + assert_eq!(record.text(), "bob\t12\t@@music\\a.flac"); + } + + #[test] + fn download_text_is_just_the_local_path() { + let record = DownloadRecord { + user: "bob".into(), + path: "@@music\\a.flac".into(), + size: 12, + file: "/music/a.flac".into(), + }; + assert_eq!(record.text(), "/music/a.flac"); + } + + #[test] + fn room_text_leads_with_the_user_count() { + let record = RoomRecord { + room: "indie rock".into(), + users: 42, + }; + assert_eq!(record.text(), "42\tindie rock"); + } + + #[test] + fn room_event_type_is_first_and_serializes_as_type() { + let record = RoomEventRecord { + kind: "message", + room: "lobby".into(), + user: "bob".into(), + message: "hi".into(), + }; + assert_eq!(record.text(), "message\tlobby\tbob\thi"); + let json = serde_json::to_value(&record).unwrap(); + assert_eq!(json["type"], "message"); + } + + #[test] + fn portmap_text_reports_status_backend_and_address() { + let ok = PortmapRecord { + ok: true, + backend: "upnp".into(), + external: Some("1.2.3.4:2234".into()), + port: 2234, + }; + assert_eq!(ok.text(), "ok\tupnp\t1.2.3.4:2234"); + let failed = PortmapRecord { + ok: false, + backend: "none".into(), + external: None, + port: 2234, + }; + assert_eq!(failed.text(), "failed\tnone\t-"); + } +} diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index 356feaa..21f59b7 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -78,9 +78,9 @@ pub const DEFAULT_SEARCH_TIMEOUT: u64 = 10; /// Layer CLI/env values over the config file over defaults. /// -/// The `--disable-listener` flag can only enable the disable (a bare flag -/// has no "explicitly off" form), so file `disable_listener = true` wins -/// unless the flag is passed. +/// The listener has an explicit form in both directions: `--listener` beats +/// `--no-listener` (and `SOULSEEK_NO_LISTENER`), which beats the file's +/// `disable_listener`, so a config file can always be overridden per run. #[must_use] pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { let download_dir = cli @@ -99,8 +99,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .listener_port .or(file.listener_port) .unwrap_or(DEFAULT_LISTENER_PORT), - disable_listener: cli.disable_listener - || file.disable_listener.unwrap_or(false), + disable_listener: if cli.listener { + false + } else { + cli.no_listener || file.disable_listener.unwrap_or(false) + }, download_dir: download_dir.clone(), shared_dirs: resolve_shared_dirs(cli, file, &download_dir), max_concurrent_downloads: cli @@ -111,7 +114,10 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .search_timeout .or(file.search_timeout) .unwrap_or(DEFAULT_SEARCH_TIMEOUT), - password_cmd: file.password_cmd.clone(), + password_cmd: cli + .password_cmd + .clone() + .or_else(|| file.password_cmd.clone()), } } @@ -125,7 +131,7 @@ fn resolve_shared_dirs( file: &FileConfig, download_dir: &str, ) -> Vec { - if cli.shared_dir.is_none() + if cli.shared_dir.is_empty() && file.shared_dir.is_none() && file.shared_dirs.is_none() { @@ -148,20 +154,7 @@ mod tests { use crate::cli::Cli; fn bare_cli() -> Cli { - Cli { - username: None, - password: None, - server: None, - disable_listener: false, - listener_port: None, - verbose: 0, - log_file: None, - command: None, - download_dir: None, - shared_dir: None, - max_concurrent_downloads: None, - search_timeout: None, - } + Cli::default() } #[test] @@ -244,7 +237,7 @@ mod tests { #[test] fn cli_shared_dir_combines_with_file_list_and_dedupes() { let mut cli = bare_cli(); - cli.shared_dir = Some("/a".into()); + cli.shared_dir = vec!["/a".into()]; let file = FileConfig { shared_dirs: Some(vec!["/a".into(), "/b".into()]), ..FileConfig::default() @@ -256,6 +249,53 @@ mod tests { ); } + #[test] + fn repeated_cli_shared_dirs_are_all_kept() { + let mut cli = bare_cli(); + cli.shared_dir = vec!["/a".into(), "/b".into()]; + let resolved = resolve(&cli, &FileConfig::default()); + assert_eq!( + resolved.shared_dirs, + vec!["/a".to_string(), "/b".to_string()] + ); + } + + #[test] + fn an_empty_cli_shared_dir_disables_sharing() { + let mut cli = bare_cli(); + cli.shared_dir = vec![String::new()]; + let resolved = resolve(&cli, &FileConfig::default()); + assert!(resolved.shared_dirs.is_empty()); + } + + #[test] + fn no_listener_flag_disables_the_listener() { + let mut cli = bare_cli(); + cli.no_listener = true; + assert!(resolve(&cli, &FileConfig::default()).disable_listener); + } + + #[test] + fn listener_flag_overrides_the_config_file() { + let file = FileConfig { + disable_listener: Some(true), + ..FileConfig::default() + }; + assert!(resolve(&bare_cli(), &file).disable_listener); + + let mut cli = bare_cli(); + cli.listener = true; + assert!(!resolve(&cli, &file).disable_listener); + } + + #[test] + fn listener_flag_wins_over_no_listener() { + let mut cli = bare_cli(); + cli.listener = true; + cli.no_listener = true; + assert!(!resolve(&cli, &FileConfig::default()).disable_listener); + } + #[test] fn file_values_override_defaults() { let file = FileConfig { diff --git a/soulseek-rs/src/port_mapping/mod.rs b/soulseek-rs/src/port_mapping/mod.rs index 6fcfdb8..4787084 100644 --- a/soulseek-rs/src/port_mapping/mod.rs +++ b/soulseek-rs/src/port_mapping/mod.rs @@ -61,43 +61,69 @@ impl Drop for PortMapper { } } +/// The outcome of a port-mapping probe: a verdict a script can branch on +/// plus the advice a human needs when it fails. +pub struct Probe { + pub ok: bool, + /// `upnp`, `natpmp`, or `none`. + pub backend: &'static str, + /// External `address:port` the mapping exposes, when the backend reports + /// one. + pub external: Option, + pub advice: String, +} + /// Synchronously probe whether automatic port mapping works on this network: -/// try to map `port` (UPnP then NAT-PMP), report the result in human-readable -/// form, then remove the probe mapping. Used by the `portmap` CLI command so a -/// user can verify reachability on their actual router. +/// try to map `port` (UPnP then NAT-PMP), then remove the probe mapping. +/// Backs the `portmap` command so a user can verify reachability on their +/// actual router. #[must_use] -pub fn diagnose(port: u16) -> String { +pub fn probe(port: u16) -> Probe { let Some(local_ip) = local_lan_ip() else { - return "Could not determine this machine's LAN IP — are you connected \ - to a network?" - .to_string(); + return Probe { + ok: false, + backend: "none", + external: None, + advice: "Could not determine this machine's LAN IP — are you \ + connected to a network?" + .to_string(), + }; }; match map_once(local_ip, port) { Some((backend, _lease)) => { - let detail = match &backend { - Backend::Upnp { gateway, .. } => { - let ip = gateway.get_external_ip().map_or_else( - |_| "unknown".to_string(), - |ip| ip.to_string(), - ); - format!("UPnP-IGD (external address {ip}:{port})") - } - Backend::NatPmp { .. } => "NAT-PMP".to_string(), + let (name, external) = match &backend { + Backend::Upnp { gateway, .. } => ( + "upnp", + gateway + .get_external_ip() + .ok() + .map(|ip| format!("{ip}:{port}")), + ), + Backend::NatPmp { .. } => ("natpmp", None), }; remove(&backend); // clean up the probe mapping - format!( - "✅ Automatic port mapping works via {detail}.\n TCP {port} \ - was opened on your router, so firewalled peers and the server \ - can reach you. The app does this automatically while it runs." - ) + Probe { + ok: true, + backend: name, + external, + advice: format!( + "Automatic port mapping works. TCP {port} was opened on \ + your router, so firewalled peers and the server can \ + reach you. The app does this automatically while it runs." + ), + } } - None => format!( - "❌ No UPnP/NAT-PMP router responded for TCP {port}.\n Automatic \ - mapping won't work on this network. To browse/download firewalled \ - peers, forward TCP {port} to this machine on your router (or \ - enable UPnP there)." - ), + None => Probe { + ok: false, + backend: "none", + external: None, + advice: format!( + "No UPnP/NAT-PMP router responded for TCP {port}. To \ + browse/download firewalled peers, forward TCP {port} to this \ + machine on your router (or enable UPnP there)." + ), + }, } } diff --git a/soulseek-rs/src/ui/download_selector.rs b/soulseek-rs/src/ui/download_selector.rs deleted file mode 100644 index 40e3170..0000000 --- a/soulseek-rs/src/ui/download_selector.rs +++ /dev/null @@ -1,636 +0,0 @@ -use crate::models::FileDisplayData; -use crate::ui::{ - BYTES_PER_MB, HIGHLIGHT_SYMBOL, format_bytes, format_shortcuts_styled, - get_bitrate, get_spinner_char, header_style, highlight_style, pane_block, - plain_title, primary_style, success_style, warning_style, -}; -use color_eyre::Result; -use ratatui::text::{Line, Span}; -use ratatui::{ - DefaultTerminal, Frame, - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll}, - layout::{Alignment, Constraint, Layout}, - style::Style, - widgets::{ - Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table, - TableState, Wrap, - }, -}; -use soulseek_rs::Client; -use std::{ - collections::HashSet, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::{Duration, Instant}, -}; - -pub struct FileSelector { - all_items: Vec, - items: Vec, - filtered_indices: Vec, - state: TableState, - should_exit: bool, - selected_indices: HashSet, - filter_query: String, - is_filtering: bool, - client: Option>, - soulseek_query: String, - search_timeout: Duration, - search_start_time: Instant, - search_cancel_flag: Arc, - search_active: bool, - spinner_state: usize, - last_spinner_update: Instant, - last_result_count: usize, -} - -impl FileSelector { - pub fn new_with_live_search( - client: Arc, - query: String, - timeout: Duration, - cancel_flag: Arc, - ) -> Self { - let mut state = TableState::default(); - state.select(Some(0)); - - Self { - all_items: Vec::new(), - items: Vec::new(), - filtered_indices: Vec::new(), - state, - should_exit: false, - selected_indices: HashSet::new(), - filter_query: String::new(), - is_filtering: false, - client: Some(client), - soulseek_query: query, - search_timeout: timeout, - search_start_time: Instant::now(), - search_cancel_flag: cancel_flag, - search_active: true, - spinner_state: 0, - last_spinner_update: Instant::now(), - last_result_count: 0, - } - } - - pub fn run( - &mut self, - mut terminal: DefaultTerminal, - ) -> Result<(DefaultTerminal, Vec)> { - while !self.should_exit { - terminal.draw(|frame| self.render(frame))?; - self.spinner_state = (self.spinner_state + 1) % 10; - - self.last_spinner_update = Instant::now(); - - // Poll for new search results if active - if self.search_active { - if let Some(ref client) = self.client { - let current_count = - client.get_search_results_count(&self.soulseek_query); - if current_count != self.last_result_count { - self.update_results_from_client(); - self.last_result_count = current_count; - } - } - - // Check if search timeout reached - if self.search_start_time.elapsed() >= self.search_timeout { - self.search_active = false; - self.search_cancel_flag.store(true, Ordering::Relaxed); - } - } - - let timeout = if self.search_active { - Duration::from_millis(50) - } else { - Duration::from_millis(100) - }; - - if poll(timeout)? - && let Event::Key(key) = event::read()? - { - self.handle_key(key); - } - } - - Ok((terminal, self.selected_indices.iter().copied().collect())) - } - - fn update_results_from_client(&mut self) { - if let Some(ref client) = self.client { - let search_results = - client.get_search_results(&self.soulseek_query); - - // Convert search results to FileDisplayData - let mut new_items = Vec::new(); - for result in &search_results { - for file in &result.files { - new_items.push(FileDisplayData { - filename: file.name.clone(), - size: file.size, - username: result.username.clone(), - speed: result.speed, - slots: result.slots, - bitrate: get_bitrate(&file.attribs), - length_seconds: file.attribs.get(&1).copied(), - }); - } - } - - let len = new_items.len(); - self.all_items = new_items.clone(); - self.items = new_items; - self.filtered_indices = (0..len).collect(); - - // Keep selection valid or set to 0 - if self.state.selected().is_none() && !self.items.is_empty() { - self.state.select(Some(0)); - } - } - } - - fn toggle_selection(&mut self) { - if let Some(filtered_idx) = self.state.selected() - && let Some(&original_idx) = self.filtered_indices.get(filtered_idx) - { - if self.selected_indices.contains(&original_idx) { - self.selected_indices.remove(&original_idx); - } else { - self.selected_indices.insert(original_idx); - } - } - } - - fn handle_key(&mut self, key: KeyEvent) { - if key.kind != KeyEventKind::Press { - return; - } - - if self.is_filtering { - match key.code { - KeyCode::Char(' ') => { - self.toggle_selection(); - } - KeyCode::Char(c) => { - self.filter_query.push(c); - self.apply_filter(); - } - KeyCode::Backspace => { - self.filter_query.pop(); - self.apply_filter(); - } - KeyCode::Esc => { - self.filter_query.clear(); - self.apply_filter(); - self.is_filtering = false; - } - KeyCode::Enter => { - // If nothing selected, select the current item under cursor - if self.selected_indices.is_empty() - && let Some(filtered_idx) = self.state.selected() - && let Some(&original_idx) = - self.filtered_indices.get(filtered_idx) - { - self.selected_indices.insert(original_idx); - } - self.should_exit = true; - } - KeyCode::Up | KeyCode::Down => { - if key.code == KeyCode::Up { - self.select_previous(); - } else { - self.select_next(); - } - } - _ => {} - } - } else { - match key.code { - KeyCode::Char(' ') => { - self.toggle_selection(); - } - KeyCode::Char('/') => { - self.is_filtering = true; - } - KeyCode::Up | KeyCode::Char('k') => self.select_previous(), - KeyCode::Down | KeyCode::Char('j') => self.select_next(), - KeyCode::Home | KeyCode::Char('g') => self.select_first(), - KeyCode::End | KeyCode::Char('G') => self.select_last(), - KeyCode::Char('a') => { - // Select all visible/filtered items - for &original_idx in &self.filtered_indices { - self.selected_indices.insert(original_idx); - } - } - KeyCode::Char('A') => { - // Deselect all items - self.selected_indices.clear(); - } - KeyCode::Enter => { - // If nothing selected, select the current item under cursor - if self.selected_indices.is_empty() - && let Some(filtered_idx) = self.state.selected() - && let Some(&original_idx) = - self.filtered_indices.get(filtered_idx) - { - self.selected_indices.insert(original_idx); - } - self.should_exit = true; - } - KeyCode::Esc | KeyCode::Char('q') => { - self.selected_indices.clear(); - self.should_exit = true; - } - _ => {} - } - } - } - - /// Previous index with wrap-around, or None when the list is empty. - const fn wrapping_prev( - selected: Option, - len: usize, - ) -> Option { - if len == 0 { - return None; - } - Some(match selected { - Some(i) if i > 0 => i - 1, - _ => len - 1, - }) - } - - /// Next index with wrap-around, or None when the list is empty. - const fn wrapping_next( - selected: Option, - len: usize, - ) -> Option { - if len == 0 { - return None; - } - Some(match selected { - Some(i) if i < len - 1 => i + 1, - _ => 0, - }) - } - - const fn select_previous(&mut self) { - let i = Self::wrapping_prev(self.state.selected(), self.items.len()); - self.state.select(i); - } - - const fn select_next(&mut self) { - let i = Self::wrapping_next(self.state.selected(), self.items.len()); - self.state.select(i); - } - - fn select_first(&mut self) { - self.state.select((!self.items.is_empty()).then_some(0)); - } - - const fn select_last(&mut self) { - self.state.select(self.items.len().checked_sub(1)); - } - - fn apply_filter(&mut self) { - let query_lower = self.filter_query.to_lowercase(); - - if query_lower.is_empty() { - self.items = self.all_items.clone(); - self.filtered_indices = (0..self.all_items.len()).collect(); - } else { - self.items.clear(); - self.filtered_indices.clear(); - - for (i, item) in self.all_items.iter().enumerate() { - if item.filename.to_lowercase().contains(&query_lower) - || item.username.to_lowercase().contains(&query_lower) - { - self.items.push(item.clone()); - self.filtered_indices.push(i); - } - } - } - - if self.items.is_empty() { - self.state.select(None); - } else { - self.state.select(Some(0)); - } - } - - fn render(&mut self, frame: &mut Frame) { - let area = frame.area(); - - // Always split layout into table, optional info, and controls footer - let has_info = self.is_filtering - || !self.selected_indices.is_empty() - || !self.filter_query.is_empty(); - - let chunks = if has_info { - Layout::vertical([ - Constraint::Min(0), - Constraint::Length(3), - Constraint::Length(3), - ]) - .split(area) - } else { - Layout::vertical([Constraint::Min(0), Constraint::Length(3)]) - .split(area) - }; - - let table_area = chunks[0]; - let (info_area, controls_area) = if has_info { - (Some(chunks[1]), chunks[2]) - } else { - (None, chunks[1]) - }; - - self.render_results_table(frame, table_area); - - if self.items.is_empty() { - self.render_loading_message(frame, table_area); - } - - if let Some(info_area) = info_area { - self.render_info(frame, info_area); - } - - self.render_controls(frame, controls_area); - } - - fn table_title(&self) -> String { - if self.search_active { - // Live Soulseek search is active - let spinner = get_spinner_char(self.spinner_state); - let elapsed = self.search_start_time.elapsed().as_secs(); - let total = self.search_timeout.as_secs(); - format!( - "{} Searching: '{}' - {} results ({}/{}s)", - spinner, - self.soulseek_query, - self.all_items.len(), - elapsed, - total - ) - } else if self.is_filtering { - format!( - "Multi-select files to download ({}/{} matches)", - self.items.len(), - self.all_items.len() - ) - } else { - format!( - "Multi-select files to download ({} selected)", - self.selected_indices.len() - ) - } - } - - fn render_results_table( - &mut self, - frame: &mut Frame, - table_area: ratatui::layout::Rect, - ) { - let title = self.table_title(); - - let header = Row::new(vec![ - Cell::from(""), - Cell::from("Filename"), - Cell::from("Size"), - Cell::from("Username"), - Cell::from("Speed"), - Cell::from("Slots"), - Cell::from("Bitrate"), - ]) - .style(header_style()) - .height(1); - - let rows: Vec = self - .items - .iter() - .enumerate() - .map(|(filtered_idx, item)| { - let original_idx = self.filtered_indices[filtered_idx]; - let is_selected = self.selected_indices.contains(&original_idx); - let checkbox = if is_selected { "[✓]" } else { "[ ]" }; - - let speed_mb = f64::from(item.speed) / BYTES_PER_MB; - let speed_str = if speed_mb > 0.0 { - format!("{speed_mb:.1} MB/s") - } else { - "-".to_string() - }; - - let slots_str = format!("{}", item.slots); - - let bitrate_str = match item.bitrate { - Some(br) => format!("{br} kbps"), - None => "-".to_string(), - }; - - let cells = vec![ - Cell::from(checkbox), - Cell::from(item.filename.clone()), - Cell::from(format_bytes(item.size)), - Cell::from(item.username.clone()), - Cell::from(speed_str), - Cell::from(slots_str), - Cell::from(bitrate_str), - ]; - - let style = if is_selected { - success_style() - } else { - Style::default() - }; - - Row::new(cells).style(style).height(1) - }) - .collect(); - - let widths = [ - Constraint::Length(5), - Constraint::Min(30), - Constraint::Length(12), - Constraint::Length(15), - Constraint::Length(12), - Constraint::Length(8), - Constraint::Length(10), - ]; - - let table = Table::new(rows, widths) - .header(header) - .block(pane_block(true).title(title)) - .column_spacing(1) - .row_highlight_style(highlight_style()) - .highlight_symbol(HIGHLIGHT_SYMBOL) - .highlight_spacing(HighlightSpacing::Always); - - StatefulWidget::render( - table, - table_area, - frame.buffer_mut(), - &mut self.state, - ); - } - - fn render_loading_message( - &self, - frame: &mut Frame, - table_area: ratatui::layout::Rect, - ) { - let spinner = get_spinner_char(self.spinner_state); - - let mut loading_message: Vec = Vec::new(); - if self.items.is_empty() && self.search_active { - loading_message.extend(vec![ - Span::raw(spinner), - Span::raw(" Searching for '"), - Span::styled(self.soulseek_query.clone(), primary_style()), - Span::raw(format!( - "' [{}/{}s]", - self.search_start_time.elapsed().as_secs(), - self.search_timeout.as_secs() - )), - ]); - } else { - loading_message.extend(vec![ - Span::raw(spinner), - Span::raw(" Searching for '"), - Span::styled(self.soulseek_query.clone(), primary_style()), - Span::raw("'; No results yet"), - ]); - } - - // Center the loading message - let vertical = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(3), - Constraint::Fill(1), - ]) - .split(table_area); - - let loading_message_line = Line::from(loading_message); - // Calculate responsive width: text + generous padding for borders and spacing, max 80% of screen - let text_width = - loading_message_line.to_string().chars().count() as u16 + 5; // +10 for borders, padding, and safety margin - let max_width = (table_area.width * 80) / 100; - let widget_width = text_width.min(max_width); - - let horizontal = Layout::horizontal([ - Constraint::Fill(1), - Constraint::Length(widget_width), - Constraint::Fill(1), - ]) - .split(vertical[1]); - - let loading_widget = Paragraph::new(loading_message_line) - .style(Style::default()) - .block(pane_block(false)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - - frame.render_widget(loading_widget, horizontal[1]); - } - - fn render_info(&self, frame: &mut Frame, info_area: ratatui::layout::Rect) { - let (info_text, title, style) = if self.is_filtering { - ( - format!("Filter: {}", self.filter_query), - "Filter", - warning_style(), - ) - } else if !self.filter_query.is_empty() { - ( - format!( - "Current filter: {} (press / to modify, Esc to clear)", - self.filter_query - ), - "Filter", - primary_style(), - ) - } else { - ( - format!( - "{} file(s) selected for download", - self.selected_indices.len() - ), - "Selection", - success_style(), - ) - }; - - let info_widget = Paragraph::new(info_text) - .block(pane_block(false).title(plain_title(title, false))) - .style(style); - - frame.render_widget(info_widget, info_area); - } - - fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) { - let controls_line = if self.is_filtering { - format_shortcuts_styled(&[ - ("Space", "toggle"), - ("a", "select-all"), - ("A", "deselect-all"), - ("Enter", "download"), - ("Esc", "exit filter"), - ]) - } else if self.search_active { - format_shortcuts_styled(&[ - ("Space", "toggle"), - ("a", "select-all"), - ("A", "deselect-all"), - ("Enter", "download"), - ("Esc/q", "cancel"), - ]) - } else { - format_shortcuts_styled(&[ - ("Space", "toggle"), - ("a", "select-all"), - ("A", "deselect-all"), - ("Enter", "download"), - ("Esc/q", "cancel"), - ("/", "filter"), - ]) - }; - - let controls_widget = Paragraph::new(controls_line) - .block(pane_block(false).title(plain_title("Controls", false))); - - frame.render_widget(controls_widget, area); - } -} - -#[cfg(test)] -mod tests { - use super::FileSelector; - - // Navigating an empty list (e.g. a filter that matched nothing) must not - // underflow `len - 1`; it should yield no selection. - #[test] - fn wrapping_prev_on_empty_list_is_none() { - assert_eq!(FileSelector::wrapping_prev(None, 0), None); - assert_eq!(FileSelector::wrapping_prev(Some(0), 0), None); - } - - #[test] - fn wrapping_next_on_empty_list_is_none() { - assert_eq!(FileSelector::wrapping_next(None, 0), None); - assert_eq!(FileSelector::wrapping_next(Some(0), 0), None); - } - - #[test] - fn wrapping_navigation_wraps_around() { - assert_eq!(FileSelector::wrapping_prev(Some(0), 3), Some(2)); - assert_eq!(FileSelector::wrapping_prev(Some(2), 3), Some(1)); - assert_eq!(FileSelector::wrapping_next(Some(2), 3), Some(0)); - assert_eq!(FileSelector::wrapping_next(Some(1), 3), Some(2)); - } -} diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index cdfbd77..cd2fa7e 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -1,305 +1,20 @@ use crate::models::DownloadEntry; use crate::ui::{ - BYTES_PER_MB, HIGHLIGHT_SYMBOL, accent_style, body_style, dimmed_style, - download_status_glyph, error_style, format_bytes_progress, - format_progress_bar, format_shortcuts_styled, format_speed, header_style, - highlight_style, info_style, pane_block, plain_title, primary_style, - success_style, warning_style, + BYTES_PER_MB, accent_style, dimmed_style, error_style, + format_bytes_progress, format_progress_bar, info_style, pane_block, + plain_title, primary_style, success_style, warning_style, }; -use color_eyre::Result; use ratatui::{ - DefaultTerminal, Frame, - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll}, + Frame, layout::{Alignment, Constraint, Layout}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{ - Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table, - TableState, - }, -}; -use soulseek_rs::{Client, DownloadStatus}; -use std::{ - sync::{Arc, mpsc, mpsc::Receiver}, - thread, - time::Duration, + widgets::Paragraph, }; +use soulseek_rs::DownloadStatus; const VERSION: &str = env!("CARGO_PKG_VERSION"); -pub struct MultiDownloadProgress { - downloads: Vec, - receiver_channel: - Receiver<(soulseek_rs::types::Download, Receiver)>, - list_state: TableState, - max_concurrent: usize, - active_count: usize, - should_exit: bool, - queuing_status: String, -} - -impl MultiDownloadProgress { - pub fn new( - receiver_channel: Receiver<( - soulseek_rs::types::Download, - Receiver, - )>, - max_concurrent: usize, - ) -> Self { - let mut list_state = TableState::default(); - list_state.select(Some(0)); - - Self { - downloads: Vec::new(), - receiver_channel, - list_state, - max_concurrent, - active_count: 0, - should_exit: false, - queuing_status: String::from("Queuing downloads..."), - } - } - - pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> { - self.start_next_batch(); - - loop { - terminal.draw(|frame| self.render(frame))?; - - while let Ok((download, receiver)) = - self.receiver_channel.try_recv() - { - self.downloads.push(DownloadEntry { - download, - receiver: Some(receiver), - }); - self.queuing_status = - format!("{} downloads queued", self.downloads.len()); - } - - // Poll all receivers for status updates - let mut need_start_next = false; - for download_entry in &mut self.downloads { - if let Some(receiver) = &download_entry.receiver - && let Ok(status) = receiver.try_recv() - { - let was_active = !download_entry.download.is_finished(); - download_entry.download.status = status; - let is_finished = download_entry.download.is_finished(); - - // If download just finished, decrement active count - if was_active && is_finished { - self.active_count = self.active_count.saturating_sub(1); - need_start_next = true; - } - } - } - - if need_start_next { - self.start_next_batch(); - } - - if poll(Duration::from_millis(100))? - && let Event::Key(key) = event::read()? - { - self.handle_key(key); - } - - // Exit when user cancels or all downloads finished - if self.should_exit || self.all_finished() { - break; - } - } - - Ok(()) - } - - fn start_next_batch(&mut self) { - for download_entry in &self.downloads { - if self.active_count >= self.max_concurrent { - break; - } - - // Only start if download is queued AND receiver is available - if matches!(download_entry.download.status, DownloadStatus::Queued) - && download_entry.receiver.is_some() - { - self.active_count += 1; - // Download will start automatically since receiver exists - } - } - } - - fn all_finished(&self) -> bool { - self.downloads.iter().all(|d| d.download.is_finished()) - } - - fn handle_key(&mut self, key: KeyEvent) { - if key.kind != KeyEventKind::Press { - return; - } - - match key.code { - KeyCode::Up | KeyCode::Char('k') => self.select_previous(), - KeyCode::Down | KeyCode::Char('j') => self.select_next(), - KeyCode::Home | KeyCode::Char('g') => self.select_first(), - KeyCode::End | KeyCode::Char('G') => self.select_last(), - KeyCode::Esc | KeyCode::Char('q') => { - self.should_exit = true; - } - _ => {} - } - } - - const fn select_previous(&mut self) { - let i = match self.list_state.selected() { - Some(i) if i > 0 => i - 1, - _ => self.downloads.len().saturating_sub(1), - }; - self.list_state.select(Some(i)); - } - - const fn select_next(&mut self) { - let i = match self.list_state.selected() { - Some(i) if i < self.downloads.len().saturating_sub(1) => i + 1, - _ => 0, - }; - self.list_state.select(Some(i)); - } - - const fn select_first(&mut self) { - self.list_state.select(Some(0)); - } - - const fn select_last(&mut self) { - if !self.downloads.is_empty() { - self.list_state.select(Some(self.downloads.len() - 1)); - } - } - - fn render(&mut self, frame: &mut Frame) { - let area = frame.area(); - - let chunks = Layout::vertical([ - Constraint::Length(3), - Constraint::Min(0), - Constraint::Length(3), - ]) - .split(area); - - // Top: Overall stats - self.render_stats(frame, chunks[0]); - - // Middle: List of downloads - self.render_downloads_list(frame, chunks[1]); - - // Bottom: Controls - Self::render_controls(frame, chunks[2]); - } - - fn render_stats(&self, frame: &mut Frame, area: ratatui::layout::Rect) { - render_download_stats(frame, area, &self.downloads, self.active_count); - } - - fn render_downloads_list( - &mut self, - frame: &mut Frame, - area: ratatui::layout::Rect, - ) { - let header = Row::new(vec![ - Cell::from("St"), - Cell::from("Filename"), - Cell::from("Username"), - Cell::from("Size"), - Cell::from("Progress"), - Cell::from("Speed"), - ]) - .style(header_style()) - .height(1); - - let rows: Vec = self - .downloads - .iter() - .map(|download_entry| { - let download = &download_entry.download; - let (status_icon, status_style) = - download_status_glyph(&download.status); - - let progress = if download.size > 0 { - download.bytes_downloaded() as f64 / download.size as f64 - } else { - 0.0 - }; - - let percent = (progress * 100.0) as u8; - let bar_width = 20; - let progress_bar = - format_progress_bar(progress, bar_width, percent); - - let size_str = format_bytes_progress( - download.bytes_downloaded(), - download.size, - ); - - let speed_str = match download.status { - DownloadStatus::InProgress { .. } => { - format_speed(download.speed_bytes_per_sec()) - } - _ => "-".to_string(), - }; - - let cells = vec![ - Cell::from(status_icon).style(status_style), - Cell::from(download.filename.clone()).style(body_style()), - Cell::from(download.username.clone()).style(info_style()), - Cell::from(size_str).style(warning_style()), - Cell::from(progress_bar), - Cell::from(speed_str).style(dimmed_style()), - ]; - - Row::new(cells).height(1) - }) - .collect(); - - let widths = [ - Constraint::Length(3), - Constraint::Min(30), - Constraint::Length(15), - Constraint::Length(20), - Constraint::Length(28), - Constraint::Length(12), - ]; - - let table = Table::new(rows, widths) - .header(header) - .block(pane_block(true).title(plain_title("Downloads", true))) - .column_spacing(1) - .row_highlight_style(highlight_style()) - .highlight_symbol(HIGHLIGHT_SYMBOL) - .highlight_spacing(HighlightSpacing::Always); - - StatefulWidget::render( - table, - area, - frame.buffer_mut(), - &mut self.list_state, - ); - } - - fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) { - let controls_line = format_shortcuts_styled(&[ - ("↑↓/jk", "scroll"), - ("Home/End", "jump"), - ("Esc/q", "cancel all"), - ]); - - let controls_widget = Paragraph::new(controls_line) - .block(pane_block(false).title(plain_title("Controls", false))); - - frame.render_widget(controls_widget, area); - } -} - /// Bytes transferred, bytes expected and combined speed across the downloads /// that are actually running — or `None` when none are, so the caller can hide /// the progress readout entirely. @@ -432,49 +147,6 @@ pub fn render_download_stats( frame.render_widget(progress_paragraph, chunks[1]); } -pub fn show_multi_download_progress( - terminal: DefaultTerminal, - client: Arc, - selected_files: Vec<(String, String, u64)>, - download_dir: String, - max_concurrent: usize, -) -> Result<()> { - soulseek_rs::utils::logger::enable_buffering(); - - let (tx, rx) = mpsc::channel(); - - // Spawn background thread to initialize downloads - let init_client = client; - thread::spawn(move || { - for (filename, username, size) in selected_files { - // Initiate download - match init_client.download( - filename.clone(), - username.clone(), - size, - download_dir.clone(), - ) { - Ok((download, receiver)) => { - let _ = tx.send((download, receiver)); - } - Err(e) => { - soulseek_rs::warn!( - "Failed to start download for {filename}: {e}" - ); - } - } - } - }); - - let mut progress = MultiDownloadProgress::new(rx, max_concurrent); - let result = progress.run(terminal); - ratatui::restore(); - - soulseek_rs::utils::logger::flush_buffered_logs(); - - result -} - #[cfg(test)] mod tests { use super::{DownloadEntry, DownloadStatus, active_progress}; diff --git a/soulseek-rs/src/ui/mod.rs b/soulseek-rs/src/ui/mod.rs index fc07415..dbafefe 100644 --- a/soulseek-rs/src/ui/mod.rs +++ b/soulseek-rs/src/ui/mod.rs @@ -1,4 +1,3 @@ -mod download_selector; mod downloads; pub mod login; mod main_tui; @@ -6,8 +5,7 @@ mod panes; mod styles; mod utils; -pub use download_selector::FileSelector; -pub use downloads::{render_download_stats, show_multi_download_progress}; +pub use downloads::render_download_stats; pub use main_tui::launch_main_tui; pub use styles::*; pub use utils::*; diff --git a/soulseek-rs/src/ui/utils.rs b/soulseek-rs/src/ui/utils.rs index 247d1b0..c0bbf35 100644 --- a/soulseek-rs/src/ui/utils.rs +++ b/soulseek-rs/src/ui/utils.rs @@ -30,12 +30,6 @@ pub fn format_speed(speed_bytes_per_sec: f64) -> String { format!("{mb:.1} MB/s") } -pub fn get_bitrate( - attribs: &std::collections::HashMap, -) -> Option { - attribs.get(&0).copied() -} - const SPINNER_CHARS: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs new file mode 100644 index 0000000..801ea28 --- /dev/null +++ b/soulseek-rs/tests/cli_e2e.rs @@ -0,0 +1,977 @@ +//! End-to-end tests of the scriptable command surface, driving the real +//! binary the way a script would: arguments in, records on stdout, progress on +//! stderr, a verdict in the exit code. +//! +//! The tests split in two. The first half needs no server and always runs. +//! The second half talks to a real Soulseek server (soulfind) and follows the +//! same server-optional rule as the library suite: with `SOULFIND_BIN` (or a +//! sibling `soulfind/bin/soulfind` checkout) it spawns one, otherwise it skips +//! with a notice. `SOULSEEK_E2E_REQUIRED=1` turns a missing server into a +//! failure, which is how CI guarantees these actually run. + +#![allow(clippy::doc_markdown)] + +use soulseek_rs::{Client, ClientSettings, PeerAddress}; +use std::io::Write; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const BIN: &str = env!("CARGO_BIN_EXE_soulseek-rs"); + +/// Exit codes the command surface promises. Kept as literals so a change to +/// the enum has to be a deliberate change here too. +const EXIT_OK: i32 = 0; +const EXIT_USAGE: i32 = 2; +const EXIT_CONNECTION: i32 = 3; +const EXIT_NO_RESULTS: i32 = 4; + +/// Every environment variable the CLI reads, cleared for each run so a +/// developer's shell (or a stray `.env`) cannot change what a test observes. +const CLI_ENV_VARS: [&str; 11] = [ + "SOULSEEK_USERNAME", + "SOULSEEK_PASSWORD", + "SOULSEEK_PASSWORD_CMD", + "SOULSEEK_SERVER", + "SOULSEEK_NO_LISTENER", + "SOULSEEK_LISTENER_PORT", + "SOULSEEK_DOWNLOAD_DIR", + "SOULSEEK_SHARED_DIR", + "SOULSEEK_MAX_CONCURRENT_DOWNLOADS", + "SOULSEEK_SEARCH_TIMEOUT", + "SOULSEEK_CONFIG", +]; + +/// Build a command for the binary, isolated from the developer's environment +/// and from the repository's own working directory. +fn command(args: &[&str]) -> Command { + let mut command = Command::new(BIN); + for name in CLI_ENV_VARS { + command.env_remove(name); + } + command.current_dir(std::env::temp_dir()); + command.args(args); + command +} + +fn run(args: &[&str]) -> Output { + command(args).output().expect("the binary should run") +} + +fn code(output: &Output) -> i32 { + output + .status + .code() + .expect("the process should not be signalled") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +/// Records are one per line; blank trailing lines are not records. +fn records(output: &Output) -> Vec { + stdout(output) + .lines() + .filter(|line| !line.trim().is_empty()) + .map(String::from) + .collect() +} + +// --------------------------------------------------------------------------- +// No server required +// --------------------------------------------------------------------------- + +#[test] +fn help_lists_every_scriptable_command() { + let output = run(&["--help"]); + assert_eq!(code(&output), EXIT_OK); + let help = stdout(&output); + for command in [ + "search", "download", "get", "browse", "room", "message", "portmap", + ] { + assert!(help.contains(command), "--help should mention {command}"); + } +} + +#[test] +fn help_documents_the_exit_codes_a_script_branches_on() { + let help = stdout(&run(&["--help"])); + assert!(help.contains("Exit codes"), "help should list exit codes"); + for code in ["2", "3", "4", "5", "6"] { + assert!(help.contains(code), "exit code {code} should be documented"); + } +} + +#[test] +fn an_unknown_flag_is_a_usage_error() { + let output = run(&["--definitely-not-a-flag"]); + assert_eq!(code(&output), EXIT_USAGE); +} + +#[test] +fn missing_credentials_are_a_usage_error_with_a_pointer_to_the_fix() { + let output = run(&["--no-config", "search", "anything"]); + assert_eq!(code(&output), EXIT_USAGE); + assert!(stderr(&output).contains("SOULSEEK_USERNAME")); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); +} + +#[test] +fn a_falsey_boolean_environment_variable_is_accepted() { + // `DISABLE_LISTENER=1` used to abort every invocation with a clap parse + // error, which made the binary unusable from a container. + for value in ["1", "0", "true", "false", "yes", ""] { + let output = command(&["--no-config", "search", "anything"]) + .env("SOULSEEK_NO_LISTENER", value) + .output() + .expect("the binary should run"); + assert!( + !stderr(&output).contains("invalid value"), + "SOULSEEK_NO_LISTENER={value:?} should be accepted" + ); + } +} + +#[test] +fn global_flags_are_accepted_after_the_subcommand() { + let output = run(&["search", "anything", "--no-config", "--json"]); + assert_eq!( + code(&output), + EXIT_USAGE, + "should reach the credential check, not a parse error" + ); + assert!(!stderr(&output).contains("unexpected argument")); +} + +#[test] +fn the_password_environment_value_is_not_echoed_in_help() { + let output = command(&["--help"]) + .env("SOULSEEK_PASSWORD", "hunter2") + .output() + .expect("the binary should run"); + assert!( + !stdout(&output).contains("hunter2"), + "--help must not print the password it inherited" + ); +} + +#[test] +fn download_without_a_target_is_a_usage_error() { + assert_eq!(code(&run(&["download", "--no-config"])), EXIT_USAGE); + assert_eq!(code(&run(&["download", "--no-config", "bob"])), EXIT_USAGE); +} + +#[test] +fn download_stdin_cannot_be_combined_with_positional_arguments() { + let output = run(&["download", "--stdin", "bob", "file.mp3"]); + assert_eq!(code(&output), EXIT_USAGE); +} + +#[test] +fn a_malformed_server_address_is_a_usage_error() { + let output = run(&[ + "--no-config", + "--username", + "u", + "--password", + "p", + "--server", + "no-port-here", + "room", + "list", + ]); + assert_eq!(code(&output), EXIT_USAGE); +} + +#[test] +fn an_unreachable_server_is_a_connection_error() { + // Port 1 is reserved and nothing listens on it, so the connect fails fast. + let output = run(&[ + "--no-config", + "--username", + "u", + "--password", + "p", + "--server", + "127.0.0.1:1", + "--no-listener", + "room", + "list", + ]); + assert_eq!(code(&output), EXIT_CONNECTION); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); +} + +#[test] +fn without_a_terminal_the_tui_reports_instead_of_panicking() { + // Captured stdout is not a terminal, which is exactly the cron/CI case. + let output = run(&["--no-config"]); + assert_eq!(code(&output), EXIT_USAGE); + assert!( + stderr(&output).contains("terminal"), + "should explain why, got: {}", + stderr(&output) + ); +} + +#[test] +fn portmap_emits_a_record_whose_verdict_matches_the_exit_code() { + let output = run(&["--no-config", "--quiet", "--json", "portmap"]); + let record: serde_json::Value = + serde_json::from_str(records(&output).first().expect("one record")) + .expect("portmap should emit valid JSON"); + + assert!(record["port"].is_number()); + let ok = record["ok"].as_bool().expect("ok should be a boolean"); + // Whether a router answers depends on the network, but the exit code and + // the record must always agree. + assert_eq!( + ok, + code(&output) == EXIT_OK, + "exit code and the ok field must agree" + ); + if !ok { + assert_eq!(code(&output), EXIT_NO_RESULTS); + } +} + +#[test] +fn quiet_keeps_progress_off_stderr() { + let loud = run(&["--no-config", "--json", "portmap"]); + let quiet = run(&["--no-config", "--quiet", "--json", "portmap"]); + assert!( + stderr(&quiet).lines().count() < stderr(&loud).lines().count(), + "--quiet should suppress the progress narration" + ); + assert_eq!( + records(&loud).len(), + records(&quiet).len(), + "--quiet must not change the records" + ); +} + +// --------------------------------------------------------------------------- +// Server required (soulfind) +// --------------------------------------------------------------------------- + +/// Only one test at a time may drive a server. +/// +/// Each of these tests runs a soulfind, one or more real clients, and the +/// binary itself. Letting the harness start a dozen of those at once starves +/// the servers, and a login that goes unanswered fails a test for a reason +/// that has nothing to do with the code under test. Serializing here keeps the +/// suite deterministic whatever `--test-threads` the runner picks. +static SERVER_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A soulfind we spawned for one test, held for that test's lifetime. +struct TestServer { + host: String, + port: u16, + child: Option, + db: Option, + _gate: std::sync::MutexGuard<'static, ()>, +} + +impl TestServer { + fn resolve() -> Option { + // A test that panicked while holding the gate poisoned it; the lock + // guards nothing but scheduling, so take it back and carry on. + let gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") { + let (host, port) = addr.rsplit_once(':')?; + let port = port.parse().ok()?; + wait_until_listening(host, port, Duration::from_secs(2))?; + return Some(Self { + host: host.to_string(), + port, + child: None, + db: None, + _gate: gate, + }); + } + + let bin = soulfind_binary()?; + let port = free_port()?; + let db = std::env::temp_dir().join(format!("soulfind-cli-{port}.db")); + let _ = std::fs::remove_file(&db); + + let mut child = Command::new(&bin) + .arg("-p") + .arg(port.to_string()) + .arg("-d") + .arg(&db) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + if wait_until_listening("127.0.0.1", port, Duration::from_secs(5)) + .is_none() + { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + + Some(Self { + host: "127.0.0.1".to_string(), + port, + child: Some(child), + db: Some(db), + _gate: gate, + }) + } + + fn address(&self) -> String { + format!("{}:{}", self.host, self.port) + } + + /// Arguments that point the binary at this server as `user`. + /// + /// The listener stays on: a Soulseek transfer is delivered over a + /// connection the *uploader* opens back to us, so a downloader without a + /// listener never receives its file. + fn args(&self, user: &str) -> Vec { + vec![ + "--no-config".to_string(), + "--quiet".to_string(), + "--server".to_string(), + self.address(), + "--username".to_string(), + user.to_string(), + "--password".to_string(), + "pw".to_string(), + "--listener-port".to_string(), + free_port().expect("listener port").to_string(), + ] + } + + /// An in-process client, used to stand up the other end of a test. + fn client(&self, user: &str, shares: Vec) -> Client { + let port = free_port().expect("peer port"); + let mut client = Client::with_settings(ClientSettings { + username: user.to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new(self.host.clone(), self.port), + enable_listen: true, + listen_port: port, + shared_directories: shares, + }); + client.connect().expect("peer connect"); + assert!(client.login().expect("peer login"), "peer should log in"); + client + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(db) = self.db.as_ref() { + let _ = std::fs::remove_file(db); + } + } +} + +fn soulfind_binary() -> Option { + if let Ok(path) = std::env::var("SOULFIND_BIN") { + let path = PathBuf::from(path); + return path.exists().then_some(path); + } + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .map(|dir| dir.join("soulfind/bin/soulfind")) + .find(|candidate| candidate.exists()) +} + +fn free_port() -> Option { + std::net::TcpListener::bind("127.0.0.1:0") + .ok()? + .local_addr() + .ok() + .map(|addr| addr.port()) +} + +fn wait_until_listening( + host: &str, + port: u16, + timeout: Duration, +) -> Option<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Ok(mut addrs) = format!("{host}:{port}").to_socket_addrs() + && let Some(addr) = addrs.next() + && TcpStream::connect_timeout(&addr, Duration::from_millis(200)) + .is_ok() + { + return Some(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +macro_rules! server_or_skip { + () => { + match TestServer::resolve() { + Some(server) => server, + None => { + let required = std::env::var("SOULSEEK_E2E_REQUIRED") + .is_ok_and(|v| v != "0" && !v.is_empty()); + assert!( + !required, + "SOULSEEK_E2E_REQUIRED is set but no soulfind server could \ + be started (set SOULFIND_BIN or SOULSEEK_TEST_SERVER)" + ); + println!( + "cli e2e skipped: no soulfind server (set SOULFIND_BIN or \ + SOULSEEK_TEST_SERVER to run)" + ); + return; + } + } + }; +} + +/// A scratch directory that cleans itself up. +struct Scratch(PathBuf); + +impl Scratch { + fn new(label: &str) -> Self { + static COUNTER: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "soulseek-cli-e2e-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("scratch directory"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn display(&self) -> String { + self.0.display().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Contents of the file every transfer test shares. +fn probe_bytes() -> Vec { + (0..4096u32).map(|i| (i % 251) as u8).collect() +} + +/// Run the binary against `server` as `user`, appending `args`. +fn cli(server: &TestServer, user: &str, args: &[&str]) -> Output { + let mut all = server.args(user); + all.extend(args.iter().map(|a| (*a).to_string())); + let refs: Vec<&str> = all.iter().map(String::as_str).collect(); + run(&refs) +} + +/// Wait out the SetWaitPort registrations so peer lookups resolve. +fn settle() { + std::thread::sleep(Duration::from_secs(1)); +} + +#[test] +fn a_wrong_password_is_a_connection_error() { + let server = server_or_skip!(); + // Registers the name with password "pw". + let _owner = server.client("cli_e2e_owner", Vec::new()); + + let output = run(&[ + "--no-config", + "--quiet", + "--server", + &server.address(), + "--username", + "cli_e2e_owner", + "--password", + "definitely-wrong", + "--no-listener", + "room", + "list", + ]); + assert_eq!(code(&output), EXIT_CONNECTION); + assert!(stdout(&output).is_empty()); +} + +#[test] +fn search_prints_a_record_that_download_can_consume() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_qwix.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_a", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_seeker_a", + &["--search-timeout", "5", "search", "qwix"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = records(&output) + .into_iter() + .find(|line| line.contains("cli_probe_qwix")) + .expect("the sharer's file should be listed"); + + // user TAB size TAB bitrate TAB path — the columns `download --stdin` + // reads back. + let fields: Vec<&str> = record.split('\t').collect(); + assert_eq!(fields.len(), 4, "record was {record:?}"); + assert_eq!(fields[0], "cli_e2e_sharer_a"); + assert_eq!(fields[1], probe_bytes().len().to_string()); + assert!(fields[3].contains("cli_probe_qwix.bin")); +} + +#[test] +fn search_json_records_carry_the_fields_a_filter_needs() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_zorb.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_b", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_seeker_b", + &["--search-timeout", "5", "--json", "search", "zorb"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record: serde_json::Value = records(&output) + .iter() + .map(|line| { + serde_json::from_str::(line) + .expect("each line should be valid JSON") + }) + .find(|value| { + value["path"].as_str().is_some_and(|p| p.contains("zorb")) + }) + .expect("the sharer's file should be listed"); + + assert_eq!(record["user"], "cli_e2e_sharer_b"); + assert_eq!(record["size"], probe_bytes().len() as u64); + assert!(record["free_slot"].is_boolean()); + assert!(record.get("speed").is_some()); +} + +#[test] +fn a_search_with_no_matches_exits_with_the_no_results_code() { + let server = server_or_skip!(); + let _peer = server.client("cli_e2e_quiet_peer", Vec::new()); + settle(); + + let output = cli( + &server, + "cli_e2e_seeker_c", + &["--search-timeout", "3", "search", "nothingmatchesthisqzx"], + ); + assert_eq!(code(&output), EXIT_NO_RESULTS); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); +} + +#[test] +fn browse_lists_shares_as_paths_download_understands() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("browsable.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_c", vec![share.display()]); + settle(); + + let output = + cli(&server, "cli_e2e_browser", &["browse", "cli_e2e_sharer_c"]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = records(&output) + .into_iter() + .find(|line| line.contains("browsable.bin")) + .expect("the shared file should be listed"); + + // user TAB size TAB path + let fields: Vec<&str> = record.split('\t').collect(); + assert_eq!(fields.len(), 3, "record was {record:?}"); + assert_eq!(fields[0], "cli_e2e_sharer_c"); + assert_eq!(fields[1], probe_bytes().len().to_string()); +} + +#[test] +fn download_fetches_a_file_and_prints_where_it_landed() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("fetch_me.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_d", vec![share.display()]); + settle(); + + // Learn the peer-side path the way a script would. + let listing = + cli(&server, "cli_e2e_fetcher", &["browse", "cli_e2e_sharer_d"]); + assert_eq!(code(&listing), EXIT_OK, "stderr: {}", stderr(&listing)); + let row = records(&listing) + .into_iter() + .find(|line| line.contains("fetch_me.bin")) + .expect("the shared file should be listed"); + let remote_path = row.rsplit('\t').next().expect("path column").to_string(); + + let output = cli( + &server, + "cli_e2e_fetcher", + &[ + "--download-dir", + &target.display(), + "download", + "cli_e2e_sharer_d", + &remote_path, + "--size", + &probe_bytes().len().to_string(), + "--timeout", + "40", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let reported = records(&output); + assert_eq!(reported.len(), 1, "one file, one record"); + let written = std::fs::read(reported[0].trim()) + .expect("the reported path should be the file that was written"); + assert_eq!(written, probe_bytes()); +} + +#[test] +fn download_without_a_size_looks_it_up_in_the_peers_shares() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("sizeless.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_g", vec![share.display()]); + settle(); + + let listing = + cli(&server, "cli_e2e_sizeless", &["browse", "cli_e2e_sharer_g"]); + let remote_path = records(&listing) + .into_iter() + .find(|line| line.contains("sizeless.bin")) + .and_then(|row| row.rsplit('\t').next().map(String::from)) + .expect("the shared file should be listed"); + + // No --size: the size has to come from the peer's listing, and the remote + // path there is the full backslash path, not the bare file name. + let output = cli( + &server, + "cli_e2e_sizeless", + &[ + "--download-dir", + &target.display(), + "download", + "cli_e2e_sharer_g", + &remote_path, + "--timeout", + "40", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let reported = records(&output); + assert_eq!(reported.len(), 1); + assert_eq!( + std::fs::read(reported[0].trim()).expect("downloaded file"), + probe_bytes() + ); +} + +#[test] +fn download_reads_the_records_search_emits_from_stdin() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("piped.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_e", vec![share.display()]); + settle(); + + let listing = + cli(&server, "cli_e2e_piper", &["browse", "cli_e2e_sharer_e"]); + let row = records(&listing) + .into_iter() + .find(|line| line.contains("piped.bin")) + .expect("the shared file should be listed"); + + let mut args = server.args("cli_e2e_piper"); + args.extend( + [ + "--download-dir", + &target.display(), + "download", + "--stdin", + "--timeout", + "40", + ] + .iter() + .map(|a| (*a).to_string()), + ); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let mut child = command(&refs) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should run"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(format!("{row}\n").as_bytes()) + .expect("write the record"); + let output = child.wait_with_output().expect("the binary should finish"); + + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + let reported = records(&output); + assert_eq!(reported.len(), 1); + assert_eq!( + std::fs::read(reported[0].trim()).expect("downloaded file"), + probe_bytes() + ); +} + +#[test] +fn get_searches_picks_and_downloads_in_one_command() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("cli_probe_vroom.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_f", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_getter", + &[ + "--search-timeout", + "5", + "--download-dir", + &target.display(), + "get", + "vroom", + "--timeout", + "40", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let reported = records(&output); + assert_eq!(reported.len(), 1, "--pick best downloads one file"); + assert_eq!( + std::fs::read(reported[0].trim()).expect("downloaded file"), + probe_bytes() + ); +} + +#[test] +fn get_without_matches_exits_no_results_and_downloads_nothing() { + let server = server_or_skip!(); + let target = Scratch::new("downloads"); + let _peer = server.client("cli_e2e_silent_peer", Vec::new()); + settle(); + + let output = cli( + &server, + "cli_e2e_getter_b", + &[ + "--search-timeout", + "3", + "--download-dir", + &target.display(), + "get", + "nothingmatchesthisqzx", + ], + ); + assert_eq!(code(&output), EXIT_NO_RESULTS); + assert_eq!( + std::fs::read_dir(target.path()) + .expect("download dir") + .count(), + 0, + "nothing should have been written" + ); +} + +#[test] +fn room_say_reaches_a_listener_and_room_list_shows_the_room() { + let server = server_or_skip!(); + let listener = server.client("cli_e2e_room_listener", Vec::new()); + listener.join_room("cli_e2e_lobby").expect("join"); + settle(); + + let said = cli( + &server, + "cli_e2e_speaker", + &["room", "say", "cli_e2e_lobby", "hello from a script"], + ); + assert_eq!(code(&said), EXIT_OK, "stderr: {}", stderr(&said)); + assert!( + stdout(&said).is_empty(), + "an action command emits no records" + ); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut heard = false; + while Instant::now() < deadline && !heard { + heard = listener.take_room_events().iter().any(|event| { + matches!( + event, + soulseek_rs::types::RoomEvent::Message { message, .. } + if message == "hello from a script" + ) + }); + std::thread::sleep(Duration::from_millis(100)); + } + assert!(heard, "the room should have received the message"); + + let listed = cli(&server, "cli_e2e_lister", &["room", "list"]); + assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed)); + let row = records(&listed) + .into_iter() + .find(|line| line.ends_with("cli_e2e_lobby")) + .expect("the joined room should be listed"); + let (users, _) = row.split_once('\t').expect("users TAB room"); + assert!( + users.parse::().is_ok(), + "the user count should be a bare number, got {users:?}" + ); +} + +#[test] +fn room_listen_streams_what_is_said_in_the_room() { + let server = server_or_skip!(); + let speaker = server.client("cli_e2e_room_speaker", Vec::new()); + speaker.join_room("cli_e2e_lobby_b").expect("join"); + settle(); + + let mut args = server.args("cli_e2e_room_reader"); + args.extend( + [ + "room", + "listen", + "cli_e2e_lobby_b", + "--duration", + "8", + "--json", + ] + .iter() + .map(|a| (*a).to_string()), + ); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let child = command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should run"); + + // Give the listener time to join before saying anything. + std::thread::sleep(Duration::from_secs(3)); + speaker + .say_in_room("cli_e2e_lobby_b", "streamed line") + .expect("say"); + + let output = child.wait_with_output().expect("the binary should finish"); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let heard = records(&output).iter().any(|line| { + serde_json::from_str::(line) + .is_ok_and(|value| value["message"] == "streamed line") + }); + assert!(heard, "stdout was: {:?}", stdout(&output)); +} + +#[test] +fn a_private_message_reaches_its_recipient() { + let server = server_or_skip!(); + let recipient = server.client("cli_e2e_recipient", Vec::new()); + settle(); + + let output = cli( + &server, + "cli_e2e_sender", + &["message", "send", "cli_e2e_recipient", "ping from a script"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + stdout(&output).is_empty(), + "an action command emits no records" + ); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut received = false; + while Instant::now() < deadline && !received { + received = recipient + .take_private_messages() + .iter() + .any(|message| message.message() == "ping from a script"); + std::thread::sleep(Duration::from_millis(100)); + } + assert!(received, "the recipient should have received the message"); +} + +#[test] +fn message_read_streams_incoming_private_messages() { + let server = server_or_skip!(); + let sender = server.client("cli_e2e_pm_sender", Vec::new()); + settle(); + + let mut args = server.args("cli_e2e_pm_reader"); + args.extend( + ["--json", "message", "read", "--duration", "8"] + .iter() + .map(|a| (*a).to_string()), + ); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let child = command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should run"); + + std::thread::sleep(Duration::from_secs(3)); + sender + .send_private_message("cli_e2e_pm_reader", "inbox line") + .expect("send"); + + let output = child.wait_with_output().expect("the binary should finish"); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let read = records(&output).iter().any(|line| { + serde_json::from_str::(line).is_ok_and(|value| { + value["message"] == "inbox line" + && value["user"] == "cli_e2e_pm_sender" + }) + }); + assert!(read, "stdout was: {:?}", stdout(&output)); +}