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