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:
@@ -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-");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user