feat(cli): expose sharing, peer lookup and settings as one-shot commands

An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.

New commands:

  serve [--follow]                stay online sharing; one record per upload
  whoami                          credentials, connection and what we offer
  user <NAME>                     a peer's presence and share counts
  room users <ROOM>               the member roster
  shares list|add|remove|status|reindex
  config path|list|get|set

`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.

Library additions, all request/poll pairs matching the existing conventions:

- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
  GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
  logged as unhandled. The wire format is taken from soulfind's own source
  and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
  arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
  observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
  message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
This commit is contained in:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent be6581ee6c
commit 03e8924a6b
21 changed files with 2187 additions and 19 deletions
+110
View File
@@ -24,6 +24,8 @@ Examples:
| 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
soulseek-rs serve --follow --json share files, stream upload events
soulseek-rs user someuser is this peer online and worth it
Exit codes:
0 success 2 usage/config 3 connect or login 4 no results
@@ -172,10 +174,104 @@ pub enum Commands {
#[command(subcommand)]
Message(MessageCommand),
/// Stay online sharing files, reporting each upload as it happens
Serve(ServeArgs),
/// What the server knows about another user
User(UserArgs),
/// Confirm the credentials and connection work
Whoami,
/// The folders this client shares
#[command(subcommand)]
Shares(SharesCommand),
/// Read and write the config file
#[command(subcommand)]
Config(ConfigCommand),
/// Check whether the router opens the listen port (UPnP/NAT-PMP)
Portmap,
}
#[derive(Args, Debug)]
pub struct ServeArgs {
/// Seconds to stay online before exiting
#[arg(
long,
default_value_t = 3600,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
pub duration: u64,
/// Stay online until interrupted, ignoring --duration
#[arg(long, action = ArgAction::SetTrue)]
pub follow: bool,
}
#[derive(Args, Debug)]
pub struct UserArgs {
/// The user to ask the server about
pub user: String,
/// Seconds to wait for the server's answer
#[arg(
short,
long,
default_value_t = 10,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
pub timeout: u64,
}
#[derive(Subcommand, Debug)]
pub enum SharesCommand {
/// List the configured share folders
List,
/// Add a folder to the shares and save it to the config file
Add {
/// Folder to start sharing
directory: String,
},
/// Stop sharing a folder and save that to the config file
Remove {
/// Folder to stop sharing
directory: String,
},
/// Re-index the shares and report what the network will see
Reindex,
/// Report how many folders and files are indexed
Status,
}
#[derive(Subcommand, Debug)]
pub enum ConfigCommand {
/// Print the path of the config file in use
Path,
/// Print every setting and its effective value
List,
/// Print one setting
Get {
/// Setting name, as shown by `config list`
key: String,
},
/// Change one setting in the config file
Set {
/// Setting name, as shown by `config list`
key: String,
/// New value; an empty string clears it
value: String,
},
}
/// How to order search results.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum SortKey {
@@ -319,6 +415,20 @@ pub enum RoomCommand {
message: String,
},
/// List who is currently in a room
Users {
/// Room whose members to list
room: String,
/// Seconds to wait for the membership list
#[arg(
short,
long,
default_value_t = 10,
value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS)
)]
timeout: u64,
},
/// Stream messages and joins/leaves from a room
Listen {
/// Room to listen to
+13 -1
View File
@@ -3,6 +3,8 @@
//! 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 peer;
pub mod settings;
pub mod social;
pub mod transfer;
@@ -171,6 +173,9 @@ pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
Commands::Room(RoomCommand::Say { room, message }) => {
social::room_say(ctx, &room, &message)
}
Commands::Room(RoomCommand::Users { room, timeout }) => {
social::room_users(ctx, &room, Duration::from_secs(timeout))
}
Commands::Room(RoomCommand::Listen {
room,
duration,
@@ -182,8 +187,15 @@ pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
Commands::Message(MessageCommand::Read { duration, follow }) => {
social::message_read(ctx, listen_span(duration, follow))
}
// Handled before credentials are required; see main.rs.
Commands::Serve(args) => peer::serve(ctx, &args),
Commands::User(args) => peer::user(ctx, &args),
Commands::Whoami => peer::whoami(ctx),
// These need no account, so main.rs answers them before asking for
// credentials; the arms exist only to keep the match exhaustive.
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
Commands::Config(_) | Commands::Shares(_) => {
Err(CliError::usage("handled without a session; see main::run"))
}
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Being a peer on the network: `serve`, `whoami`, and `user`.
use super::{Ctx, Session};
use crate::cli::{ServeArgs, UserArgs};
use crate::output::{
CliError, CliResult, UploadRecord, UserRecord, WhoamiRecord,
};
use soulseek_rs::types::{UploadInfo, UploadStatus, UserInfo};
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// How often the serve loop samples the upload table.
const POLL: Duration = Duration::from_millis(500);
pub fn whoami(ctx: &Ctx) -> CliResult {
let session = Session::open(ctx)?;
let (folders, files) = session.client.shared_counts();
ctx.out.emit(&WhoamiRecord {
user: ctx.settings.username.clone(),
server: ctx.settings.server_address.to_string(),
listening: ctx.settings.enable_listen,
listen_port: ctx.settings.listen_port,
shared_folders: folders,
shared_files: files,
download_dir: ctx.download_dir.clone(),
});
Ok(())
}
pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult {
let session = Session::open(ctx)?;
session.client.request_user_info(&args.user).map_err(|e| {
CliError::connection(format!("cannot ask about {}: {e}", args.user))
})?;
ctx.out
.status(&format!("asking the server about {}…", args.user));
// Presence and statistics are two replies; wait for both, but report
// whichever arrived rather than defaulting the other to something that
// reads like an answer.
let deadline = Instant::now() + Duration::from_secs(args.timeout);
let mut latest = None;
while Instant::now() < deadline {
latest = session.client.user_info(&args.user);
if latest.as_ref().is_some_and(UserInfo::is_complete) {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let Some(info) = latest else {
return Err(CliError::timeout(format!(
"the server said nothing about {} within {}s",
args.user, args.timeout
)));
};
if !info.is_complete() {
ctx.out
.warn("the server answered only partly; unknown fields are null");
}
ctx.out.emit(&UserRecord {
user: info.username,
status: info.presence.map(|p| p.status.to_string()),
privileged: info.presence.map(|p| p.privileged),
average_speed: info.stats.map(|s| s.average_speed),
shared_files: info.stats.map(|s| s.shared_files),
shared_folders: info.stats.map(|s| s.shared_folders),
});
Ok(())
}
pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
if ctx.settings.shared_directories.is_empty() {
return Err(CliError::usage(
"nothing to share: pass --shared-dir, or add one with `shares add`",
));
}
if !ctx.settings.enable_listen {
return Err(CliError::usage(
"serving needs the listener: drop --no-listener",
));
}
let session = Session::open(ctx)?;
let (folders, files) = session.client.shared_counts();
ctx.out.status(&format!(
"serving {files} files in {folders} folders on port {}",
ctx.settings.listen_port
));
if files == 0 {
ctx.out
.warn("the share index is empty — peers will find nothing");
}
let span = (!args.follow).then(|| Duration::from_secs(args.duration));
ctx.out.status(&match span {
Some(span) => format!("online for {}s…", span.as_secs()),
None => "online until interrupted…".to_string(),
});
let deadline = span.map(|span| Instant::now() + span);
let mut reported: HashMap<(String, String), UploadRecord> = HashMap::new();
while deadline.is_none_or(|deadline| Instant::now() < deadline) {
for upload in session.client.uploads() {
let record = upload_record(&upload);
let key = (record.user.clone(), record.path.clone());
if reported.get(&key) != Some(&record) {
ctx.out.emit(&record);
reported.insert(key, record);
}
}
std::thread::sleep(POLL);
}
Ok(())
}
/// Flatten a live upload into the record `serve` reports.
fn upload_record(upload: &UploadInfo) -> UploadRecord {
let (status, reason) = match &upload.status {
UploadStatus::InProgress => ("uploading", None),
UploadStatus::Completed => ("completed", None),
UploadStatus::Cancelled => ("cancelled", None),
UploadStatus::Failed(why) => ("failed", Some(why.clone())),
};
UploadRecord {
user: upload.username.clone(),
path: upload.filename.clone(),
size: upload.size,
bytes_sent: upload.bytes_sent,
speed: upload.speed_bytes_per_sec,
status: status.to_string(),
reason,
}
}
/// Emit `record` only when it differs from the last one reported for that
/// (user, file). Exposed for tests; the serve loop inlines the same rule.
#[cfg(test)]
fn emit_changed(
reported: &mut HashMap<(String, String), UploadRecord>,
record: UploadRecord,
out: &crate::output::Out,
) -> bool {
let key = (record.user.clone(), record.path.clone());
if reported.get(&key) == Some(&record) {
return false;
}
out.emit(&record);
reported.insert(key, record);
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::{Out, Record};
fn upload(status: UploadStatus, bytes_sent: u64) -> UploadInfo {
UploadInfo {
username: "bob".to_string(),
filename: "@@x\\track.mp3".to_string(),
size: 100,
bytes_sent,
status,
speed_bytes_per_sec: 1.0,
}
}
#[test]
fn an_upload_maps_to_its_wire_status() {
assert_eq!(
upload_record(&upload(UploadStatus::InProgress, 5)).status,
"uploading"
);
assert_eq!(
upload_record(&upload(UploadStatus::Completed, 100)).status,
"completed"
);
assert_eq!(
upload_record(&upload(UploadStatus::Cancelled, 5)).status,
"cancelled"
);
let failed =
upload_record(&upload(UploadStatus::Failed("gone".into()), 5));
assert_eq!(failed.status, "failed");
assert_eq!(failed.reason.as_deref(), Some("gone"));
}
#[test]
fn the_upload_line_leads_with_status_and_progress() {
let record = upload_record(&upload(UploadStatus::InProgress, 42));
assert_eq!(record.text(), "uploading\tbob\t42/100\t@@x\\track.mp3");
}
#[test]
fn an_unchanged_upload_is_reported_once() {
let out = Out::new(false, true);
let mut reported = HashMap::new();
let record = upload_record(&upload(UploadStatus::InProgress, 42));
assert!(emit_changed(&mut reported, record.clone(), &out));
assert!(
!emit_changed(&mut reported, record, &out),
"polling must not re-report an upload that has not moved"
);
}
#[test]
fn progress_and_status_changes_are_both_reported() {
let out = Out::new(false, true);
let mut reported = HashMap::new();
emit_changed(
&mut reported,
upload_record(&upload(UploadStatus::InProgress, 10)),
&out,
);
assert!(
emit_changed(
&mut reported,
upload_record(&upload(UploadStatus::InProgress, 60)),
&out
),
"more bytes sent is news"
);
assert!(
emit_changed(
&mut reported,
upload_record(&upload(UploadStatus::Completed, 100)),
&out
),
"finishing is news"
);
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Managing what this client shares and what it remembers: `shares` and
//! `config`.
//!
//! Both write to the same `config.toml` the TUI uses, so a change made here is
//! what the next run — interactive or scripted — starts from.
use super::{Ctx, Session};
use crate::directories;
use crate::output::{
CliError, CliResult, ConfigRecord, Out, ShareRecord, ShareStatusRecord,
};
use crate::persist::config::FileConfig;
use std::path::PathBuf;
/// Where a `shares`/`config` change is written, and what it starts from.
pub struct Store {
pub path: Option<PathBuf>,
pub config: FileConfig,
}
impl Store {
fn save(&self, config: &FileConfig) -> CliResult {
let Some(path) = &self.path else {
return Err(CliError::usage(
"no config file to write to (running with --no-config)",
));
};
config
.save(path)
.map_err(|e| CliError::usage(format!("cannot save config: {e}")))
}
}
pub fn shares_list(out: &Out, store: &Store, resolved: &[String]) -> CliResult {
let directories = configured_shares(store, resolved);
if directories.is_empty() {
return Err(CliError::no_results("no folders are shared"));
}
for directory in directories {
out.emit(&ShareRecord {
usable: directories::resolve_shared_directory(Some(&directory))
.is_ok_and(|resolved| resolved.is_some()),
directory,
});
}
Ok(())
}
pub fn shares_add(out: &Out, store: &Store, directory: &str) -> CliResult {
// Refuse a folder that is not there: sharing it would silently index
// nothing, and the mistake would only surface as "no one can find my
// files" much later.
match directories::resolve_shared_directory(Some(directory)) {
Ok(Some(_)) => {}
Ok(None) => return Err(CliError::usage("no folder given")),
Err(e) => return Err(CliError::usage(e)),
}
let mut updated = store.config.clone();
let mut dirs = share_list(&store.config);
if dirs.iter().any(|existing| existing == directory) {
out.status(&format!("{directory} is already shared"));
return Ok(());
}
dirs.push(directory.to_string());
updated.shared_dirs = Some(dirs);
updated.shared_dir = None;
store.save(&updated)?;
out.status(&format!("sharing {directory}"));
Ok(())
}
pub fn shares_remove(out: &Out, store: &Store, directory: &str) -> CliResult {
let mut dirs = share_list(&store.config);
let before = dirs.len();
dirs.retain(|existing| existing != directory);
if dirs.len() == before {
return Err(CliError::no_results(format!(
"{directory} is not in the shared folders"
)));
}
let mut updated = store.config.clone();
updated.shared_dirs = Some(dirs);
updated.shared_dir = None;
store.save(&updated)?;
out.status(&format!("no longer sharing {directory}"));
Ok(())
}
/// Connect with the configured shares so the library scans them, then report
/// what the network will actually see.
pub fn shares_status(ctx: &Ctx) -> CliResult {
let session = Session::open(ctx)?;
let (folders, files) = session.client.shared_counts();
ctx.out.emit(&ShareStatusRecord {
folders,
files,
directories: ctx.settings.shared_directories.clone(),
});
Ok(())
}
/// Re-scan the configured folders in a live session, so files added since the
/// last run become visible without waiting for the next one.
pub fn shares_reindex(ctx: &Ctx) -> CliResult {
let session = Session::open(ctx)?;
ctx.out.status("re-indexing the shared folders…");
session
.client
.set_shared_directories(ctx.settings.shared_directories.clone())
.map_err(|e| CliError::usage(format!("cannot re-index: {e}")))?;
let (folders, files) = session.client.shared_counts();
ctx.out.emit(&ShareStatusRecord {
folders,
files,
directories: ctx.settings.shared_directories.clone(),
});
Ok(())
}
/// The folders the config file asks for, falling back to what this run
/// resolved (which includes `--shared-dir` and the download-folder default).
fn configured_shares(store: &Store, resolved: &[String]) -> Vec<String> {
let configured = share_list(&store.config);
if configured.is_empty() {
return resolved.to_vec();
}
configured
}
/// Both config spellings, flattened: `shared_dir` is the single-folder form.
fn share_list(config: &FileConfig) -> Vec<String> {
let mut dirs = config.shared_dirs.clone().unwrap_or_default();
if let Some(single) = &config.shared_dir
&& !single.trim().is_empty()
&& !dirs.iter().any(|existing| existing == single)
{
dirs.insert(0, single.clone());
}
dirs
}
pub fn config_path(out: &Out, store: &Store) -> CliResult {
let Some(path) = &store.path else {
return Err(CliError::no_results(
"no config file in use (running with --no-config)",
));
};
out.emit(&ConfigRecord {
key: "config".to_string(),
value: Some(path.display().to_string()),
});
Ok(())
}
pub fn config_list(out: &Out, store: &Store) {
for key in FileConfig::KEYS {
out.emit(&ConfigRecord {
key: (*key).to_string(),
value: store.config.get(key),
});
}
}
pub fn config_get(out: &Out, store: &Store, key: &str) -> CliResult {
let value = store.config.get(key).ok_or_else(|| unknown_or_unset(key))?;
out.emit(&ConfigRecord {
key: key.to_string(),
value: Some(value),
});
Ok(())
}
pub fn config_set(
out: &Out,
store: &Store,
key: &str,
value: &str,
) -> CliResult {
let mut updated = store.config.clone();
updated.set(key, value).map_err(CliError::usage)?;
store.save(&updated)?;
out.emit(&ConfigRecord {
key: key.to_string(),
value: updated.get(key),
});
Ok(())
}
fn unknown_or_unset(key: &str) -> CliError {
if FileConfig::KEYS.contains(&key) {
return CliError::no_results(format!("{key} is not set"));
}
CliError::usage(format!(
"unknown setting '{key}' — try one of: {}",
FileConfig::KEYS.join(", ")
))
}
+36 -1
View File
@@ -5,7 +5,7 @@ use super::{Ctx, Session, confirm_flush};
use crate::cli::BrowseArgs;
use crate::output::{
BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord,
RoomEventRecord, RoomRecord,
RoomEventRecord, RoomMemberRecord, RoomRecord,
};
use soulseek_rs::types::RoomEvent;
use soulseek_rs::{Client, SharedDirectory};
@@ -132,6 +132,41 @@ pub fn room_say(ctx: &Ctx, room: &str, message: &str) -> CliResult {
)))
}
/// List a room's membership. Joining is what makes the server send it, so we
/// join, wait for the roster, and leave again.
pub fn room_users(ctx: &Ctx, room: &str, timeout: Duration) -> CliResult {
let session = Session::open(ctx)?;
session.client.join_room(room).map_err(|e| {
CliError::connection(format!("cannot join {room}: {e}"))
})?;
ctx.out
.status(&format!("waiting for {room}'s member list…"));
let deadline = Instant::now() + timeout;
let mut members = Vec::new();
while Instant::now() < deadline && members.is_empty() {
members = session.client.room_members(room);
if members.is_empty() {
std::thread::sleep(POLL);
}
}
let _ = session.client.leave_room(room);
if members.is_empty() {
return Err(CliError::timeout(format!(
"{room} sent no member list within {}s",
timeout.as_secs()
)));
}
for user in members {
ctx.out.emit(&RoomMemberRecord {
room: room.to_string(),
user,
});
}
Ok(())
}
pub fn room_listen(ctx: &Ctx, room: &str, span: Option<Duration>) -> CliResult {
let session = Session::open(ctx)?;
session.client.join_room(room).map_err(|e| {
+1
View File
@@ -57,6 +57,7 @@ pub fn candidates(
bitrate: file.attribs.get(&ATTR_BITRATE).copied(),
duration: file.attribs.get(&ATTR_DURATION).copied(),
free_slot: result.slots > 0,
slots: result.slots,
speed: result.speed,
};
if filter.keeps(&candidate) {
+48 -6
View File
@@ -8,7 +8,7 @@ mod port_mapping;
mod ui;
use clap::Parser;
use cli::{Cli, Commands, parse_server_address};
use cli::{Cli, Commands, ConfigCommand, SharesCommand, parse_server_address};
use commands::Ctx;
use output::{CliError, CliResult, Exit, Out};
use soulseek_rs::{ClientSettings, PeerAddress};
@@ -50,14 +50,56 @@ fn run(mut cli: Cli, out: &Out) -> CliResult {
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);
// Anything that only reads the network stack or the config file runs
// before credentials are demanded: asking a script for a login it does not
// need would be a poor trade for a `config get`.
let store = commands::settings::Store {
path: config_path,
config: file_config,
};
match command {
Commands::Portmap => {
return commands::portmap(out, resolved.listener_port);
}
Commands::Config(ConfigCommand::Path) => {
return commands::settings::config_path(out, &store);
}
Commands::Config(ConfigCommand::List) => {
commands::settings::config_list(out, &store);
return Ok(());
}
Commands::Config(ConfigCommand::Get { ref key }) => {
return commands::settings::config_get(out, &store, key);
}
Commands::Config(ConfigCommand::Set { ref key, ref value }) => {
return commands::settings::config_set(out, &store, key, value);
}
Commands::Shares(SharesCommand::List) => {
return commands::settings::shares_list(
out,
&store,
&resolved.shared_dirs,
);
}
Commands::Shares(SharesCommand::Add { ref directory }) => {
return commands::settings::shares_add(out, &store, directory);
}
Commands::Shares(SharesCommand::Remove { ref directory }) => {
return commands::settings::shares_remove(out, &store, directory);
}
_ => {}
}
let ctx = context(&cli, &resolved, out)?;
commands::run(&ctx, command)
match command {
Commands::Shares(SharesCommand::Status) => {
commands::settings::shares_status(&ctx)
}
Commands::Shares(SharesCommand::Reindex) => {
commands::settings::shares_reindex(&ctx)
}
other => commands::run(&ctx, other),
}
}
/// Which config file to read: an explicit `--config`, the platform default,
+146
View File
@@ -174,6 +174,9 @@ pub struct SearchRecord {
pub bitrate: Option<u32>,
pub duration: Option<u32>,
pub free_slot: bool,
/// The peer's free upload slots as it reported them, for a picker that
/// wants to rank rather than just filter.
pub slots: u8,
pub speed: u32,
}
@@ -279,6 +282,147 @@ impl Record for PrivateMessageRecord {
}
}
/// One upload being served to a peer, reported by `serve` as it changes.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct UploadRecord {
pub user: String,
pub path: String,
pub size: u64,
pub bytes_sent: u64,
pub speed: f64,
/// `queued`, `uploading`, `completed`, `cancelled`, or `failed`.
pub status: String,
/// Why it failed, when it did.
pub reason: Option<String>,
}
impl Record for UploadRecord {
fn text(&self) -> String {
format!(
"{}\t{}\t{}/{}\t{}",
self.status,
sanitize(&self.user),
self.bytes_sent,
self.size,
sanitize(&self.path)
)
}
}
/// Who we are and what this session offers the network.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WhoamiRecord {
pub user: String,
pub server: String,
pub listening: bool,
pub listen_port: u16,
pub shared_folders: u32,
pub shared_files: u32,
pub download_dir: String,
}
impl Record for WhoamiRecord {
fn text(&self) -> String {
format!(
"{}\t{}\t{}\t{}",
sanitize(&self.user),
sanitize(&self.server),
self.shared_folders,
self.shared_files
)
}
}
/// What the server knows about another user.
/// Each field is `None` when the server has not answered that half yet, so a
/// consumer never reads "offline" or "shares nothing" from a missing reply.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserRecord {
pub user: String,
/// `online`, `away`, or `offline`.
pub status: Option<String>,
pub privileged: Option<bool>,
pub average_speed: Option<u32>,
pub shared_files: Option<u32>,
pub shared_folders: Option<u32>,
}
impl Record for UserRecord {
fn text(&self) -> String {
let unknown = || "-".to_string();
format!(
"{}\t{}\t{}\t{}",
sanitize(&self.user),
self.status.clone().unwrap_or_else(unknown),
self.average_speed.map_or_else(unknown, |v| v.to_string()),
self.shared_files.map_or_else(unknown, |v| v.to_string())
)
}
}
/// One member of a chat room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RoomMemberRecord {
pub room: String,
pub user: String,
}
impl Record for RoomMemberRecord {
fn text(&self) -> String {
sanitize(&self.user)
}
}
/// One configured share folder.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ShareRecord {
pub directory: String,
/// False when the folder is configured but missing on disk.
pub usable: bool,
}
impl Record for ShareRecord {
fn text(&self) -> String {
format!(
"{}\t{}",
if self.usable { "ok" } else { "missing" },
sanitize(&self.directory)
)
}
}
/// What the share index currently holds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ShareStatusRecord {
pub folders: u32,
pub files: u32,
pub directories: Vec<String>,
}
impl Record for ShareStatusRecord {
fn text(&self) -> String {
format!("{}\t{}", self.folders, self.files)
}
}
/// One configuration setting.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ConfigRecord {
pub key: String,
pub value: Option<String>,
}
impl Record for ConfigRecord {
fn text(&self) -> String {
match &self.value {
Some(value) => {
format!("{}\t{}", sanitize(&self.key), sanitize(value))
}
None => format!("{}\t", sanitize(&self.key)),
}
}
}
/// The result of probing automatic port mapping.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PortmapRecord {
@@ -311,6 +455,7 @@ mod tests {
bitrate: Some(320),
duration: Some(210),
free_slot: true,
slots: 1,
speed: 1000,
}
}
@@ -359,6 +504,7 @@ mod tests {
assert_eq!(json["bitrate"], 320);
assert_eq!(json["duration"], 210);
assert_eq!(json["free_slot"], true);
assert_eq!(json["slots"], 1);
assert_eq!(json["speed"], 1000);
assert_eq!(json["path"], "@@dir\\01 - Track.mp3");
}
+212
View File
@@ -26,6 +26,111 @@ pub struct FileConfig {
}
impl FileConfig {
/// Every setting `config get`/`set` accepts, in the order `config list`
/// prints them. Keys match the TOML field names exactly, so what a script
/// reads back is what it would have written by hand.
pub const KEYS: &'static [&'static str] = &[
"username",
"server",
"listener_port",
"disable_listener",
"download_dir",
"shared_dirs",
"max_concurrent_downloads",
"search_timeout",
"password_cmd",
];
/// The value of `key`, or `None` when it is unset or unknown. Lists are
/// rendered comma-separated, matching what [`Self::set`] accepts.
#[must_use]
pub fn get(&self, key: &str) -> Option<String> {
match key {
"username" => self.username.clone(),
"server" => self.server.clone(),
"listener_port" => self.listener_port.map(|v| v.to_string()),
"disable_listener" => self.disable_listener.map(|v| v.to_string()),
"download_dir" => self.download_dir.clone(),
"shared_dirs" => {
let dirs = self.shared_dirs.clone().unwrap_or_default();
(!dirs.is_empty()).then(|| dirs.join(","))
}
"max_concurrent_downloads" => {
self.max_concurrent_downloads.map(|v| v.to_string())
}
"search_timeout" => self.search_timeout.map(|v| v.to_string()),
"password_cmd" => self.password_cmd.clone(),
_ => None,
}
}
/// Set `key` to `value`, or clear it when `value` is empty.
///
/// # Errors
/// Returns a message naming the problem when the key is unknown or the
/// value does not parse as that setting's type.
pub fn set(
&mut self,
key: &str,
value: &str,
) -> std::result::Result<(), String> {
let value = value.trim();
let clear = value.is_empty();
let text = |target: &mut Option<String>| {
*target = (!clear).then(|| value.to_string());
};
match key {
"username" => text(&mut self.username),
"server" => text(&mut self.server),
"download_dir" => text(&mut self.download_dir),
"password_cmd" => text(&mut self.password_cmd),
"listener_port" => {
self.listener_port = parse_opt(value, clear, key)?;
}
"max_concurrent_downloads" => {
self.max_concurrent_downloads = parse_opt(value, clear, key)?;
}
"search_timeout" => {
self.search_timeout = parse_opt(value, clear, key)?;
}
"disable_listener" => {
self.disable_listener = if clear {
None
} else {
Some(match value {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
other => {
return Err(format!(
"disable_listener wants true or false, got \
'{other}'"
));
}
})
};
}
"shared_dirs" => {
self.shared_dirs = (!clear).then(|| {
value
.split(',')
.map(str::trim)
.filter(|dir| !dir.is_empty())
.map(String::from)
.collect()
});
// The single-folder spelling would otherwise shadow the list.
self.shared_dir = None;
}
other => {
return Err(format!(
"unknown setting '{other}' — try one of: {}",
Self::KEYS.join(", ")
));
}
}
Ok(())
}
/// Load from `path`; a missing file is an empty config, a malformed file
/// is an error (silently ignoring a typo'd config would be worse).
pub fn load(path: &Path) -> Result<Self> {
@@ -56,6 +161,22 @@ impl FileConfig {
}
}
/// Parse a setting that is not a string, turning a bad value into a message
/// naming the key rather than a bare parse error.
fn parse_opt<T: std::str::FromStr>(
value: &str,
clear: bool,
key: &str,
) -> std::result::Result<Option<T>, String> {
if clear {
return Ok(None);
}
value
.parse::<T>()
.map(Some)
.map_err(|_| format!("{key} does not accept '{value}'"))
}
/// Fully-resolved settings after layering CLI (which already includes env via
/// clap) over the config file over built-in defaults.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -343,6 +464,97 @@ mod tests {
assert_eq!(resolved.download_dir, "/cli-dl");
}
#[test]
fn every_advertised_key_round_trips_through_get_and_set() {
let mut config = FileConfig::default();
for key in FileConfig::KEYS {
assert!(config.get(key).is_none(), "{key} should start unset");
}
for (key, value) in [
("username", "alice"),
("server", "localhost:2242"),
("listener_port", "4321"),
("disable_listener", "true"),
("download_dir", "/music"),
("shared_dirs", "/a,/b"),
("max_concurrent_downloads", "3"),
("search_timeout", "30"),
("password_cmd", "pass show slsk"),
] {
config.set(key, value).expect("should accept");
assert_eq!(
config.get(key).as_deref(),
Some(value),
"{key} should read back what was written"
);
}
assert_eq!(config.shared_dirs, Some(vec!["/a".into(), "/b".into()]));
}
#[test]
fn an_empty_value_clears_a_setting() {
let mut config = FileConfig {
username: Some("alice".into()),
listener_port: Some(2234),
..FileConfig::default()
};
config.set("username", "").unwrap();
config.set("listener_port", "").unwrap();
assert_eq!(config.username, None);
assert_eq!(config.listener_port, None);
}
#[test]
fn a_value_of_the_wrong_type_is_rejected_by_name() {
let mut config = FileConfig::default();
let error = config.set("listener_port", "not-a-port").unwrap_err();
assert!(error.contains("listener_port"), "got {error}");
assert!(config.get("listener_port").is_none(), "must not be set");
let error = config.set("disable_listener", "maybe").unwrap_err();
assert!(error.contains("true or false"), "got {error}");
}
#[test]
fn an_unknown_key_lists_the_ones_that_exist() {
let mut config = FileConfig::default();
let error = config.set("colour", "blue").unwrap_err();
assert!(error.contains("unknown setting"), "got {error}");
assert!(error.contains("username"), "should suggest real keys");
assert!(config.get("colour").is_none());
}
#[test]
fn setting_the_share_list_drops_the_single_folder_spelling() {
// Both spellings surviving would make the effective share set depend
// on resolution order rather than on what was just written.
let mut config = FileConfig {
shared_dir: Some("/old".into()),
..FileConfig::default()
};
config.set("shared_dirs", "/new").unwrap();
assert_eq!(config.shared_dir, None);
assert_eq!(config.shared_dirs, Some(vec!["/new".to_string()]));
}
#[test]
fn a_set_value_survives_a_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut config = FileConfig::default();
config.set("download_dir", "/music").unwrap();
config.set("max_concurrent_downloads", "2").unwrap();
config.save(&path).unwrap();
let loaded = FileConfig::load(&path).unwrap();
assert_eq!(loaded.get("download_dir").as_deref(), Some("/music"));
assert_eq!(
loaded.get("max_concurrent_downloads").as_deref(),
Some("2")
);
}
#[test]
fn missing_file_loads_as_empty_config() {
let dir = tempfile::tempdir().unwrap();
+478
View File
@@ -975,3 +975,481 @@ fn message_read_streams_incoming_private_messages() {
});
assert!(read, "stdout was: {:?}", stdout(&output));
}
// ---------------------------------------------------------------------------
// Being a peer: whoami, user, serve, shares, room users
// ---------------------------------------------------------------------------
#[test]
fn whoami_confirms_the_login_and_reports_what_we_offer() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("offered.bin"), probe_bytes())
.expect("share file");
let output = cli(
&server,
"cli_e2e_whoami",
&["--shared-dir", &share.display(), "--json", "whoami"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
assert_eq!(record["user"], "cli_e2e_whoami");
assert_eq!(record["shared_files"], 1, "the shared file is indexed");
assert!(record["server"].as_str().is_some_and(|s| s.contains(':')));
}
#[test]
fn whoami_fails_with_the_connection_code_on_a_bad_password() {
let server = server_or_skip!();
let _owner = server.client("cli_e2e_whoami_owner", Vec::new());
let output = run(&[
"--no-config",
"--quiet",
"--server",
&server.address(),
"--username",
"cli_e2e_whoami_owner",
"--password",
"wrong",
"--no-listener",
"whoami",
]);
assert_eq!(code(&output), EXIT_CONNECTION);
}
#[test]
fn user_reports_a_peers_status_and_share_counts() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::create_dir_all(share.path().join("album")).expect("album");
for name in ["a.bin", "b.bin"] {
std::fs::write(share.path().join("album").join(name), probe_bytes())
.expect("share file");
}
let _subject = server.client("cli_e2e_subject", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_asker",
&["--json", "user", "cli_e2e_subject"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
assert_eq!(record["user"], "cli_e2e_subject");
assert_ne!(record["status"], "offline", "the subject is logged in");
assert_eq!(record["shared_files"], 2);
}
#[test]
fn serve_stays_online_and_reports_an_upload_a_peer_pulls() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("served.bin"), probe_bytes())
.expect("share file");
// A serving process: online, sharing, streaming upload records.
let mut args = server.args("cli_e2e_server");
args.retain(|arg| arg != "--quiet");
args.extend(
[
"--shared-dir",
&share.display(),
"--json",
"serve",
"--duration",
"25",
]
.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 it time to log in and register its listen port, then pull a file
// from it the way any peer would: browse to learn the path, then download.
std::thread::sleep(Duration::from_secs(4));
let listing = cli(&server, "cli_e2e_puller", &["browse", "cli_e2e_server"]);
let remote_path = records(&listing)
.into_iter()
.find(|line| line.contains("served.bin"))
.and_then(|row| row.rsplit('\t').next().map(String::from))
.unwrap_or_else(|| {
panic!(
"a serving process should answer a browse; exit={} stderr={}",
code(&listing),
stderr(&listing)
)
});
let puller = cli(
&server,
"cli_e2e_puller",
&[
"--download-dir",
&target.display(),
"download",
"cli_e2e_server",
&remote_path,
"--size",
&probe_bytes().len().to_string(),
"--timeout",
"20",
],
);
let output = child.wait_with_output().expect("serve should finish");
assert_eq!(code(&output), EXIT_OK, "serve stderr: {}", stderr(&output));
// The download may take the browse path for its remote name; what this
// test pins is that serve REPORTED the upload it served.
let uploads: Vec<serde_json::Value> = records(&output)
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
assert!(
!uploads.is_empty(),
"serve should report the upload. puller exit={} stderr={} serve stdout={:?}",
code(&puller),
stderr(&puller),
stdout(&output)
);
let last = &uploads[uploads.len() - 1];
assert_eq!(last["user"], "cli_e2e_puller");
assert!(
last["path"]
.as_str()
.is_some_and(|p| p.contains("served.bin"))
);
}
#[test]
fn serve_refuses_to_run_with_nothing_to_share() {
let server = server_or_skip!();
let output = cli(
&server,
"cli_e2e_empty_server",
&["--shared-dir", "", "serve", "--duration", "5"],
);
assert_eq!(code(&output), EXIT_USAGE);
assert!(stderr(&output).contains("share"), "got {}", stderr(&output));
}
#[test]
fn shares_status_reports_what_the_network_will_see() {
let server = server_or_skip!();
let share = Scratch::new("share");
for name in ["one.bin", "two.bin", "three.bin"] {
std::fs::write(share.path().join(name), probe_bytes())
.expect("share file");
}
let output = cli(
&server,
"cli_e2e_shares",
&[
"--shared-dir",
&share.display(),
"--json",
"shares",
"status",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
assert_eq!(record["files"], 3);
assert!(record["folders"].as_u64().is_some_and(|n| n >= 1));
}
#[test]
fn shares_reindex_picks_up_a_file_added_after_startup() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("first.bin"), probe_bytes())
.expect("share file");
std::fs::write(share.path().join("second.bin"), probe_bytes())
.expect("share file");
let output = cli(
&server,
"cli_e2e_reindex",
&[
"--shared-dir",
&share.display(),
"--json",
"shares",
"reindex",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
assert_eq!(record["files"], 2, "re-indexing should see both files");
}
#[test]
fn room_users_lists_who_is_in_the_room() {
let server = server_or_skip!();
let resident = server.client("cli_e2e_resident", Vec::new());
resident.join_room("cli_e2e_roster").expect("join");
settle();
let output = cli(
&server,
"cli_e2e_roster_asker",
&["room", "users", "cli_e2e_roster"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
records(&output)
.iter()
.any(|line| line == "cli_e2e_resident"),
"the resident should be listed, got {:?}",
records(&output)
);
}
// ---------------------------------------------------------------------------
// Configuration (no server needed)
// ---------------------------------------------------------------------------
/// Run the binary against a throwaway config file.
fn with_config(config: &Path, args: &[&str]) -> Output {
let mut all = vec!["--config", config.to_str().expect("utf-8 path")];
all.extend_from_slice(args);
run(&all)
}
#[test]
fn config_set_writes_a_value_that_get_reads_back() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let set =
with_config(&config, &["config", "set", "download_dir", "/music"]);
assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set));
let got = with_config(&config, &["config", "get", "download_dir"]);
assert_eq!(code(&got), EXIT_OK);
assert_eq!(records(&got), ["download_dir\t/music"]);
// And the next run actually starts from it.
let listed = with_config(&config, &["--json", "config", "list"]);
let values: Vec<serde_json::Value> = records(&listed)
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
assert!(
values
.iter()
.any(|v| v["key"] == "download_dir" && v["value"] == "/music"),
"config list should show the saved value"
);
}
#[test]
fn config_rejects_an_unknown_key_and_a_bad_value() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let unknown = with_config(&config, &["config", "set", "colour", "blue"]);
assert_eq!(code(&unknown), EXIT_USAGE);
assert!(stderr(&unknown).contains("unknown setting"));
let bad =
with_config(&config, &["config", "set", "listener_port", "not-a-port"]);
assert_eq!(code(&bad), EXIT_USAGE);
}
#[test]
fn config_get_of_an_unset_key_is_the_no_results_code() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let output = with_config(&config, &["config", "get", "username"]);
assert_eq!(code(&output), EXIT_NO_RESULTS);
}
#[test]
fn config_path_reports_the_file_in_use() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let output = with_config(&config, &["config", "path"]);
assert_eq!(code(&output), EXIT_OK);
assert!(
records(&output)[0].contains("config.toml"),
"got {:?}",
records(&output)
);
}
#[test]
fn shares_add_and_remove_persist_to_the_config_file() {
let dir = Scratch::new("config");
let shared = Scratch::new("shared");
let config = dir.path().join("config.toml");
let added = with_config(&config, &["shares", "add", &shared.display()]);
assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added));
let listed = with_config(&config, &["shares", "list"]);
assert_eq!(code(&listed), EXIT_OK);
assert!(
records(&listed)
.iter()
.any(|line| line.contains(&shared.display())),
"the added folder should be listed, got {:?}",
records(&listed)
);
let removed =
with_config(&config, &["shares", "remove", &shared.display()]);
assert_eq!(code(&removed), EXIT_OK, "stderr: {}", stderr(&removed));
let after = with_config(&config, &["shares", "list"]);
assert!(
!records(&after)
.iter()
.any(|line| line.contains(&shared.display())),
"the folder should be gone, got {:?}",
records(&after)
);
}
#[test]
fn shares_add_refuses_a_folder_that_is_not_there() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let output =
with_config(&config, &["shares", "add", "/definitely/not/here"]);
assert_eq!(code(&output), EXIT_USAGE);
assert!(
stderr(&output).contains("does not exist"),
"got {}",
stderr(&output)
);
}
/// The contract every scripted consumer relies on: with `--json`, stdout
/// carries newline-delimited JSON and nothing else, while the progress
/// narration still goes to stderr.
#[test]
fn json_mode_keeps_stdout_pure_for_every_command() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("purity_probe.bin"), probe_bytes())
.expect("share file");
let peer = server.client("cli_e2e_purity_peer", vec![share.display()]);
peer.join_room("cli_e2e_purity_room").expect("join");
settle();
// Deliberately NOT --quiet: progress must be produced, and must not land
// on stdout. Commands are run for their output shape, not their verdict —
// several legitimately exit non-zero here.
let cases: Vec<Vec<&str>> = vec![
vec!["whoami"],
vec!["search", "purity_probe"],
vec!["browse", "cli_e2e_purity_peer"],
vec!["user", "cli_e2e_purity_peer"],
vec!["room", "list"],
vec!["room", "users", "cli_e2e_purity_room"],
vec!["room", "listen", "cli_e2e_purity_room", "--duration", "2"],
vec!["message", "read", "--duration", "2"],
vec!["shares", "status"],
vec!["shares", "list"],
vec!["config", "list"],
vec!["config", "path"],
vec!["portmap"],
vec!["search", "nothing_matches_this_qzx"],
vec!["browse", "no_such_user_qzx"],
];
let mut narrated = false;
for case in cases {
let mut args = server.args("cli_e2e_purity");
args.retain(|arg| arg != "--quiet");
args.push("--json".to_string());
args.push("--search-timeout".to_string());
args.push("3".to_string());
args.extend(case.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = run(&refs);
for line in stdout(&output).lines() {
assert!(
serde_json::from_str::<serde_json::Value>(line).is_ok(),
"`{}` put a non-JSON line on stdout: {line:?}",
case.join(" ")
);
}
narrated |= !stderr(&output).trim().is_empty();
}
assert!(
narrated,
"progress should still be reported — on stderr, where it belongs"
);
}
/// Failure must never leave half a record behind for a parser to trip on.
#[test]
fn a_failing_command_writes_nothing_at_all_to_stdout() {
let server = server_or_skip!();
for case in [
vec!["search", "nothing_matches_this_qzx"],
vec!["browse", "no_such_user_qzx"],
] {
let mut args = server.args("cli_e2e_empty_stdout");
args.push("--json".to_string());
args.push("--search-timeout".to_string());
args.push("3".to_string());
args.extend(case.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = run(&refs);
assert_ne!(code(&output), EXIT_OK, "`{}` should fail", case.join(" "));
assert!(
stdout(&output).trim().is_empty(),
"`{}` failed but still wrote to stdout: {:?}",
case.join(" "),
stdout(&output)
);
}
}
/// Asking about a user nobody has heard of is a question the server answers,
/// not an error: the answer is "offline", and a script branches on the record
/// rather than on a failure.
#[test]
fn asking_about_an_unknown_user_succeeds_with_an_offline_record() {
let server = server_or_skip!();
let output = cli(
&server,
"cli_e2e_unknown_asker",
&["--json", "user", "nobody_qzx"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
assert_eq!(record["status"], "offline");
assert_eq!(record["shared_files"], 0);
}