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
+162 -417
View File
@@ -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<PathBuf> {
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<Ctx> {
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<Option<String>> {
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<String> {
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<std::path::PathBuf>,
config_path: Option<PathBuf>,
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<std::path::PathBuf>,
config_path: Option<PathBuf>,
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<Client> {
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(())
}