feat(cli)!: replace the one-shot commands with a scriptable surface

The one-shot half of the binary could not finish its headline task without a
human at the keyboard: `search` opened a full-screen picker, panicked with
exit 101 when there was no terminal, and no command could fetch a known file
at all. Everything exited 0 whatever happened, wrote emoji prose to stdout,
and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml.

The commands are now built for scripts and agents:

  search  download  get  browse  room list|say|listen  message send|read

stdout carries records only (tab-separated, or NDJSON under --json), stderr
carries progress, and the exit code carries the verdict: 2 usage, 3 connect,
4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and
fetch in one step, and `search --json | jq ... | download --stdin` composes.
Global flags parse in any position and every one has a SOULSEEK_ variable and
a config.toml key resolved through a single precedence chain.

Defects found and fixed along the way, each covered by a test:

- a download with an unknown size was finalized against zero bytes and saved
  as an empty file; the size now comes from the peer's share listing
- transfers are keyed by a hash of the remote path, so the same path from two
  peers shadowed itself and two files sharing a basename overwrote each
  other; both collisions are now dropped with a warning
- `message send` and `room say` slept 500ms and claimed success; they now
  wait for a server reply queued behind the message before reporting
- Client::login waited on an unbounded channel and never returned when the
  server went quiet

Deleted the TUI picker the old `search` drove (download_selector,
MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The
interactive TUI is otherwise untouched.

BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.
This commit is contained in:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent c2dd227cee
commit be6581ee6c
17 changed files with 3756 additions and 1583 deletions
+436
View File
@@ -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<Exit> 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<String>) -> Self {
Self {
exit,
message: message.into(),
}
}
pub fn usage(message: impl Into<String>) -> Self {
Self::new(Exit::Usage, message)
}
pub fn connection(message: impl Into<String>) -> Self {
Self::new(Exit::Connection, message)
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(Exit::Timeout, message)
}
pub fn transfer(message: impl Into<String>) -> Self {
Self::new(Exit::Transfer, message)
}
pub fn no_results(message: impl Into<String>) -> 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<T = ()> = Result<T, CliError>;
/// 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<R: Record>(&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<u32>,
pub duration: Option<u32>,
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<String>,
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-");
}
}