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:
@@ -129,9 +129,19 @@ soulseek-rs room say <ROOM> <MESSAGE> # post to a room
|
||||
soulseek-rs room listen <ROOM> # stream room messages and joins
|
||||
soulseek-rs message send <USER> <TEXT> # send a private message
|
||||
soulseek-rs message read # stream incoming private messages
|
||||
soulseek-rs room users <ROOM> # who is in a room
|
||||
soulseek-rs serve [--follow] # stay online sharing, stream uploads
|
||||
soulseek-rs whoami # confirm credentials and connection
|
||||
soulseek-rs user <NAME> # a peer's status and share counts
|
||||
soulseek-rs shares list|add|remove|status|reindex
|
||||
soulseek-rs config path|list|get|set
|
||||
soulseek-rs portmap # test automatic port mapping
|
||||
```
|
||||
|
||||
`whoami`, `config` and `shares list|add|remove` are the ones a script runs
|
||||
first: the config commands need no account at all, and `whoami` answers
|
||||
"are these credentials good and what am I offering" in one call.
|
||||
|
||||
Every command emits records except the two that perform an action —
|
||||
`room say` and `message send` say nothing and answer with their exit code
|
||||
alone. `room listen` and `message read` stream records as they arrive, until
|
||||
@@ -180,8 +190,36 @@ fi
|
||||
|
||||
# Log room chat as JSON until interrupted
|
||||
soulseek-rs room listen lobby --follow --json >> lobby.ndjson
|
||||
|
||||
# Be a peer: share a folder and log every upload served
|
||||
soulseek-rs shares add ~/Music
|
||||
soulseek-rs serve --follow --json | tee -a uploads.ndjson
|
||||
|
||||
# Decide whether a peer is worth queueing to before committing
|
||||
soulseek-rs user someuser --json | jq -e '.status != "offline"'
|
||||
```
|
||||
|
||||
### Sharing
|
||||
|
||||
`serve` is the mode that makes this client a source: it stays logged in, keeps
|
||||
the listener and the share index alive, answers searches and browse requests
|
||||
from the network, and prints one record per upload as it changes state
|
||||
(`uploading`, `completed`, `cancelled`, `failed`). It ends after `--duration`
|
||||
seconds, or runs until interrupted with `--follow`.
|
||||
|
||||
```bash
|
||||
soulseek-rs shares add ~/Music # remembered in config.toml
|
||||
soulseek-rs shares status # what the network will actually see
|
||||
soulseek-rs shares reindex # after adding files on disk
|
||||
soulseek-rs serve --follow
|
||||
```
|
||||
|
||||
Uploads are only visible inside a running `serve` — every other command is a
|
||||
short-lived process with nothing to serve — so there is no separate
|
||||
`uploads list`. Managing transfers across commands (pause, resume, a queue
|
||||
that outlives one invocation) needs the resident daemon that `serve` would
|
||||
grow into; it is not there yet.
|
||||
|
||||
Downloads run under a deadline (`--timeout`, 300s by default) and
|
||||
`--max-concurrent-downloads` at a time, so an unattended run always ends.
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ use crate::message::server::MessageUser;
|
||||
use crate::message::server::ParentMinSpeedHandler;
|
||||
use crate::message::server::ParentSpeedRatioHandler;
|
||||
use crate::message::server::PrivilegedUsersHandler;
|
||||
use crate::message::server::RoomListHandler;
|
||||
use crate::message::server::SayChatroomHandler;
|
||||
use crate::message::server::UserJoinedRoomHandler;
|
||||
use crate::message::server::UserLeftRoomHandler;
|
||||
use crate::message::server::WishListIntervalHandler;
|
||||
use crate::message::server::{
|
||||
GetUserStatsHandler, GetUserStatusHandler, RoomListHandler,
|
||||
};
|
||||
use crate::message::{Handlers, MessageType};
|
||||
use crate::message::{Message, MessageReader};
|
||||
use crate::peer::ConnectionType;
|
||||
@@ -141,7 +143,10 @@ impl UserMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server actor's mailbox. Marked non-exhaustive: each protocol message
|
||||
/// the client learns adds a variant, and that must not break callers.
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum ServerMessage {
|
||||
ProcessRead,
|
||||
LoginStatus(bool),
|
||||
@@ -175,6 +180,19 @@ pub enum ServerMessage {
|
||||
},
|
||||
PrivateMessageReceived(UserMessage),
|
||||
RoomListReceived(Vec<RoomInfo>),
|
||||
/// A `GetUserStatus` (code 7) reply.
|
||||
UserStatusReceived {
|
||||
username: String,
|
||||
status: u32,
|
||||
privileged: bool,
|
||||
},
|
||||
/// A `GetUserStats` (code 36) reply.
|
||||
UserStatsReceived {
|
||||
username: String,
|
||||
average_speed: u32,
|
||||
shared_files: u32,
|
||||
shared_folders: u32,
|
||||
},
|
||||
RoomJoined {
|
||||
room: String,
|
||||
users: Vec<String>,
|
||||
@@ -354,6 +372,8 @@ impl ServerActor {
|
||||
|
||||
handlers.register_handler(LoginHandler);
|
||||
handlers.register_handler(RoomListHandler);
|
||||
handlers.register_handler(GetUserStatusHandler);
|
||||
handlers.register_handler(GetUserStatsHandler);
|
||||
handlers.register_handler(JoinRoomHandler);
|
||||
handlers.register_handler(LeaveRoomHandler);
|
||||
handlers.register_handler(SayChatroomHandler);
|
||||
@@ -452,6 +472,30 @@ impl ServerActor {
|
||||
ServerMessage::RoomListReceived(rooms) => {
|
||||
self.forward_room_event(RoomEvent::List(rooms));
|
||||
}
|
||||
ServerMessage::UserStatusReceived {
|
||||
username,
|
||||
status,
|
||||
privileged,
|
||||
} => {
|
||||
self.forward_to_client(ClientOperation::UserStatusReceived {
|
||||
username,
|
||||
status,
|
||||
privileged,
|
||||
});
|
||||
}
|
||||
ServerMessage::UserStatsReceived {
|
||||
username,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
} => {
|
||||
self.forward_to_client(ClientOperation::UserStatsReceived {
|
||||
username,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
});
|
||||
}
|
||||
ServerMessage::RoomJoined { room, users } => {
|
||||
self.forward_room_event(RoomEvent::Joined { room, users });
|
||||
}
|
||||
@@ -566,6 +610,14 @@ impl ServerActor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand an operation to the client loop, logging a dead channel rather
|
||||
/// than unwinding the actor.
|
||||
fn forward_to_client(&self, operation: ClientOperation) {
|
||||
if let Err(e) = self.client_channel.send(operation) {
|
||||
error!("[server] Error forwarding to client: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_private_message_received(&self, user_message: UserMessage) {
|
||||
debug!("[server] Private message from {}", user_message.username());
|
||||
if let Err(e) = self
|
||||
|
||||
@@ -3,7 +3,10 @@ use crate::actor::server_actor::{
|
||||
PeerAddress, ServerActor, ServerMessage, UserMessage,
|
||||
};
|
||||
use crate::download_store::{DownloadStore, collect_failed_tokens};
|
||||
use crate::types::{DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo};
|
||||
use crate::types::{
|
||||
DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo, UserInfo,
|
||||
UserPresence, UserStats, UserStatus,
|
||||
};
|
||||
use crate::utils::logger;
|
||||
use crate::{
|
||||
Transfer,
|
||||
@@ -167,8 +170,10 @@ impl Default for ClientSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// The client loop's mailbox. Non-exhaustive for the same reason as
|
||||
/// [`ServerMessage`]: new protocol coverage adds variants.
|
||||
#[derive(Debug)]
|
||||
|
||||
#[non_exhaustive]
|
||||
pub enum ClientOperation {
|
||||
NewPeer(NewPeer),
|
||||
ConnectToPeer(Peer),
|
||||
@@ -192,6 +197,19 @@ pub enum ClientOperation {
|
||||
},
|
||||
SetServerSender(Sender<ServerMessage>),
|
||||
PrivateMessageReceived(UserMessage),
|
||||
/// The server answered `GetUserStatus` for a user we asked about.
|
||||
UserStatusReceived {
|
||||
username: String,
|
||||
status: u32,
|
||||
privileged: bool,
|
||||
},
|
||||
/// The server answered `GetUserStats` for a user we asked about.
|
||||
UserStatsReceived {
|
||||
username: String,
|
||||
average_speed: u32,
|
||||
shared_files: u32,
|
||||
shared_folders: u32,
|
||||
},
|
||||
PeerConnected(String),
|
||||
/// A search distributed to us by the server; reply if our shares match.
|
||||
IncomingSearch {
|
||||
@@ -255,6 +273,12 @@ pub struct ClientContext {
|
||||
room_list: Vec<RoomInfo>,
|
||||
/// Chat-room events awaiting consumption by the client/UI.
|
||||
room_events: Vec<RoomEvent>,
|
||||
/// Who is in each room we have joined, kept current from the membership
|
||||
/// the server sends on join plus the later join/leave events.
|
||||
room_members: HashMap<String, Vec<String>>,
|
||||
/// What the server has told us about other users, merged across the
|
||||
/// separate status and statistics replies.
|
||||
user_info: HashMap<String, UserInfo>,
|
||||
actor_system: Arc<ActorSystem>,
|
||||
}
|
||||
impl Default for ClientContext {
|
||||
@@ -549,20 +573,100 @@ impl ClientContext {
|
||||
browse_results: HashMap::new(),
|
||||
room_list: Vec::new(),
|
||||
room_events: Vec::new(),
|
||||
room_members: HashMap::new(),
|
||||
user_info: HashMap::new(),
|
||||
downloads: DownloadStore::new(),
|
||||
actor_system,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a chat-room event: keep the room-list snapshot current and queue
|
||||
/// the event for the client/UI to drain.
|
||||
/// Apply a chat-room event: keep the room-list snapshot and the per-room
|
||||
/// rosters current, then queue the event for the client/UI to drain.
|
||||
pub fn apply_room_event(&mut self, event: RoomEvent) {
|
||||
if let RoomEvent::List(rooms) = &event {
|
||||
self.room_list.clone_from(rooms);
|
||||
match &event {
|
||||
RoomEvent::List(rooms) => self.room_list.clone_from(rooms),
|
||||
RoomEvent::Joined { room, users } => {
|
||||
let mut members = users.clone();
|
||||
members.sort();
|
||||
members.dedup();
|
||||
self.room_members.insert(room.clone(), members);
|
||||
}
|
||||
RoomEvent::Left { room } => {
|
||||
self.room_members.remove(room);
|
||||
}
|
||||
RoomEvent::UserJoined { room, username } => {
|
||||
let members =
|
||||
self.room_members.entry(room.clone()).or_default();
|
||||
if let Err(at) = members.binary_search(username) {
|
||||
members.insert(at, username.clone());
|
||||
}
|
||||
}
|
||||
RoomEvent::UserLeft { room, username } => {
|
||||
if let Some(members) = self.room_members.get_mut(room)
|
||||
&& let Ok(at) = members.binary_search(username)
|
||||
{
|
||||
members.remove(at);
|
||||
}
|
||||
}
|
||||
RoomEvent::Message { .. } => {}
|
||||
}
|
||||
self.room_events.push(event);
|
||||
}
|
||||
|
||||
/// Record a `GetUserStatus` reply, merging it with any statistics already
|
||||
/// received for that user.
|
||||
pub fn apply_user_status(
|
||||
&mut self,
|
||||
username: String,
|
||||
status: u32,
|
||||
privileged: bool,
|
||||
) {
|
||||
self.user_info
|
||||
.entry(username.clone())
|
||||
.or_insert_with(|| UserInfo::pending(username))
|
||||
.presence = Some(UserPresence {
|
||||
status: UserStatus::from_code(status),
|
||||
privileged,
|
||||
});
|
||||
}
|
||||
|
||||
/// Forget what we know about `username`, so the next poll reports the
|
||||
/// answer to the request being made now rather than the previous one.
|
||||
pub fn invalidate_user_info(&mut self, username: &str) {
|
||||
self.user_info.remove(username);
|
||||
}
|
||||
|
||||
/// Record a `GetUserStats` reply, merging it with any status already
|
||||
/// received for that user.
|
||||
pub fn apply_user_stats(
|
||||
&mut self,
|
||||
username: String,
|
||||
average_speed: u32,
|
||||
shared_files: u32,
|
||||
shared_folders: u32,
|
||||
) {
|
||||
self.user_info
|
||||
.entry(username.clone())
|
||||
.or_insert_with(|| UserInfo::pending(username))
|
||||
.stats = Some(UserStats {
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
});
|
||||
}
|
||||
|
||||
/// What the server has said about `username` so far.
|
||||
#[must_use]
|
||||
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
|
||||
self.user_info.get(username).cloned()
|
||||
}
|
||||
|
||||
/// Who is currently in `room`, sorted, or empty when we are not in it.
|
||||
#[must_use]
|
||||
pub fn room_members(&self, room: &str) -> Vec<String> {
|
||||
self.room_members.get(room).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The latest snapshot of the public chat-room list.
|
||||
#[must_use]
|
||||
pub fn room_list(&self) -> Vec<RoomInfo> {
|
||||
@@ -812,10 +916,100 @@ mod uploads;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::upload_speed;
|
||||
use crate::types::UploadStatus;
|
||||
use super::{ClientContext, upload_speed};
|
||||
use crate::types::{RoomEvent, UploadStatus};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() {
|
||||
let mut context = ClientContext::new();
|
||||
|
||||
// Joining hands us the current membership in one event.
|
||||
context.apply_room_event(RoomEvent::Joined {
|
||||
room: "lobby".to_string(),
|
||||
users: vec!["bob".to_string(), "alice".to_string()],
|
||||
});
|
||||
assert_eq!(context.room_members("lobby"), ["alice", "bob"]);
|
||||
|
||||
context.apply_room_event(RoomEvent::UserJoined {
|
||||
room: "lobby".to_string(),
|
||||
username: "carol".to_string(),
|
||||
});
|
||||
context.apply_room_event(RoomEvent::UserLeft {
|
||||
room: "lobby".to_string(),
|
||||
username: "bob".to_string(),
|
||||
});
|
||||
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
|
||||
|
||||
// Events for other rooms do not leak into this one.
|
||||
context.apply_room_event(RoomEvent::UserJoined {
|
||||
room: "elsewhere".to_string(),
|
||||
username: "dave".to_string(),
|
||||
});
|
||||
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
|
||||
|
||||
// A room we never joined has no roster, and leaving forgets it.
|
||||
assert!(context.room_members("unknown").is_empty());
|
||||
context.apply_room_event(RoomEvent::Left {
|
||||
room: "lobby".to_string(),
|
||||
});
|
||||
assert!(context.room_members("lobby").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_request_discards_the_previous_answer() {
|
||||
// Without this, a poll after a second request returns the old
|
||||
// snapshot immediately and the caller cannot tell stale from fresh.
|
||||
let mut context = ClientContext::new();
|
||||
context.apply_user_status("alice".to_string(), 2, false);
|
||||
context.apply_user_stats("alice".to_string(), 10, 20, 30);
|
||||
assert!(context.user_info("alice").is_some_and(|i| i.is_complete()));
|
||||
|
||||
context.invalidate_user_info("alice");
|
||||
assert!(
|
||||
context.user_info("alice").is_none(),
|
||||
"a new request must not be answerable from the old reply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_reply_fills_only_its_own_half() {
|
||||
let mut context = ClientContext::new();
|
||||
context.apply_user_status("bob".to_string(), 1, true);
|
||||
|
||||
let info = context.user_info("bob").expect("a snapshot");
|
||||
assert!(!info.is_complete(), "stats have not arrived");
|
||||
assert_eq!(
|
||||
info.presence.map(|p| p.status),
|
||||
Some(crate::types::UserStatus::Away)
|
||||
);
|
||||
assert!(info.stats.is_none(), "must not invent statistics");
|
||||
|
||||
context.apply_user_stats("bob".to_string(), 5, 6, 7);
|
||||
let info = context.user_info("bob").expect("a snapshot");
|
||||
assert!(info.is_complete());
|
||||
assert_eq!(info.stats.map(|s| s.shared_files), Some(6));
|
||||
assert_eq!(
|
||||
info.presence.map(|p| p.privileged),
|
||||
Some(true),
|
||||
"the earlier half must survive the merge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_joining_twice_is_listed_once() {
|
||||
let mut context = ClientContext::new();
|
||||
context.apply_room_event(RoomEvent::Joined {
|
||||
room: "lobby".to_string(),
|
||||
users: vec!["alice".to_string()],
|
||||
});
|
||||
context.apply_room_event(RoomEvent::UserJoined {
|
||||
room: "lobby".to_string(),
|
||||
username: "alice".to_string(),
|
||||
});
|
||||
assert_eq!(context.room_members("lobby"), ["alice"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_speed_is_reported_only_while_running() {
|
||||
let two_seconds_ago = Instant::now()
|
||||
|
||||
@@ -492,6 +492,36 @@ impl Client {
|
||||
e
|
||||
),
|
||||
},
|
||||
ClientOperation::UserStatusReceived {
|
||||
username,
|
||||
status,
|
||||
privileged,
|
||||
} => match client_context.write_safe() {
|
||||
Ok(mut ctx) => ctx.apply_user_status(
|
||||
username, status, privileged,
|
||||
),
|
||||
Err(e) => error!(
|
||||
"[client] UserStatusReceived write: {}",
|
||||
e
|
||||
),
|
||||
},
|
||||
ClientOperation::UserStatsReceived {
|
||||
username,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
} => match client_context.write_safe() {
|
||||
Ok(mut ctx) => ctx.apply_user_stats(
|
||||
username,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
),
|
||||
Err(e) => error!(
|
||||
"[client] UserStatsReceived write: {}",
|
||||
e
|
||||
),
|
||||
},
|
||||
ClientOperation::RoomEvent(event) => {
|
||||
match client_context.write_safe() {
|
||||
Ok(mut ctx) => ctx.apply_room_event(event),
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage,
|
||||
SharedDirectory, SoulseekRs, UserMessage, error,
|
||||
};
|
||||
use crate::types::UserInfo;
|
||||
|
||||
impl Client {
|
||||
/// Send a private message to another user via the server.
|
||||
@@ -101,6 +102,60 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server what it knows about `username`: online status and share
|
||||
/// statistics.
|
||||
///
|
||||
/// Any previous answer for `username` is discarded first, so
|
||||
/// [`Client::user_info`] returns `None` until the replies to *this*
|
||||
/// request arrive — polling it cannot mistake a stale snapshot for a
|
||||
/// fresh one.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
|
||||
pub fn request_user_info(&self, username: &str) -> Result<()> {
|
||||
use crate::message::server::MessageFactory;
|
||||
self.context.write_safe()?.invalidate_user_info(username);
|
||||
self.send_server_message(MessageFactory::build_get_user_status(
|
||||
username,
|
||||
))?;
|
||||
self.send_server_message(MessageFactory::build_get_user_stats(username))
|
||||
}
|
||||
|
||||
/// What the server has told us about `username` since the last
|
||||
/// [`Client::request_user_info`], or `None` before any reply arrives.
|
||||
///
|
||||
/// Presence and statistics are separate replies, so a snapshot can carry
|
||||
/// one and not the other; [`UserInfo::is_complete`] reports when both
|
||||
/// have landed.
|
||||
#[must_use]
|
||||
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
|
||||
match self.context.read_safe() {
|
||||
Ok(ctx) => ctx.user_info(username),
|
||||
Err(e) => {
|
||||
error!("[client] user_info: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who is currently in `room`, sorted by name.
|
||||
///
|
||||
/// The roster is built from the membership the server sends when
|
||||
/// [`Client::join_room`] succeeds and kept current by the join and leave
|
||||
/// events after it, so it is populated shortly after joining rather than
|
||||
/// immediately. Returns an empty list for a room this client has not
|
||||
/// joined.
|
||||
#[must_use]
|
||||
pub fn room_members(&self, room: &str) -> Vec<String> {
|
||||
match self.context.read_safe() {
|
||||
Ok(ctx) => ctx.room_members(room),
|
||||
Err(e) => {
|
||||
error!("[client] room_members: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove and return all chat-room events received since the last call.
|
||||
#[must_use]
|
||||
pub fn take_room_events(&self) -> Vec<RoomEvent> {
|
||||
|
||||
@@ -15,7 +15,9 @@ pub mod utils;
|
||||
pub mod prelude {
|
||||
pub use crate::actor::server_actor::PeerAddress;
|
||||
pub use crate::types::{
|
||||
DownloadStatus, File, Search, SearchResult, Transfer,
|
||||
DownloadStatus, File, RoomEvent, RoomInfo, Search, SearchResult,
|
||||
Transfer, UploadInfo, UploadStatus, UserInfo, UserPresence, UserStats,
|
||||
UserStatus,
|
||||
};
|
||||
pub use crate::{debug, error, info, trace, warn};
|
||||
}
|
||||
@@ -25,4 +27,7 @@ pub use actor::server_actor::{PeerAddress, UserMessage};
|
||||
pub use client::{Client, ClientSettings};
|
||||
pub use error::{Result, SoulseekRs};
|
||||
pub use message::peer::SharedDirectory;
|
||||
pub use types::{DownloadStatus, File, Search, SearchResult, Transfer};
|
||||
pub use types::{
|
||||
DownloadStatus, File, RoomEvent, RoomInfo, Search, SearchResult, Transfer,
|
||||
UploadInfo, UploadStatus, UserInfo, UserPresence, UserStats, UserStatus,
|
||||
};
|
||||
|
||||
@@ -122,6 +122,21 @@ impl MessageFactory {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Ask the server (code 7) for a user's online status.
|
||||
#[must_use]
|
||||
pub fn build_get_user_status(username: &str) -> Message {
|
||||
Message::new().write_int32(7).write_string(username).clone()
|
||||
}
|
||||
|
||||
/// Ask the server (code 36) for a user's share statistics.
|
||||
#[must_use]
|
||||
pub fn build_get_user_stats(username: &str) -> Message {
|
||||
Message::new()
|
||||
.write_int32(36)
|
||||
.write_string(username)
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Ask the server (code 64) for the list of public chat rooms.
|
||||
#[must_use]
|
||||
pub fn build_room_list_request() -> Message {
|
||||
@@ -228,6 +243,20 @@ impl MessageFactory {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_get_user_status() {
|
||||
let message = MessageFactory::build_get_user_status("bob");
|
||||
let expect: Vec<u8> = [7, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
|
||||
assert_eq!(expect, message.get_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_get_user_stats() {
|
||||
let message = MessageFactory::build_get_user_stats("bob");
|
||||
let expect: Vec<u8> = [36, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
|
||||
assert_eq!(expect, message.get_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_watch_user() {
|
||||
let token: u32 = 223;
|
||||
|
||||
@@ -12,6 +12,7 @@ mod parent_speed_ratio;
|
||||
mod privileged_users;
|
||||
mod room_list;
|
||||
mod say_chatroom;
|
||||
mod user_info;
|
||||
mod user_joined_room;
|
||||
mod user_left_room;
|
||||
mod wish_list_interval;
|
||||
@@ -30,6 +31,7 @@ pub use parent_speed_ratio::ParentSpeedRatioHandler;
|
||||
pub use privileged_users::PrivilegedUsersHandler;
|
||||
pub use room_list::RoomListHandler;
|
||||
pub use say_chatroom::SayChatroomHandler;
|
||||
pub use user_info::{GetUserStatsHandler, GetUserStatusHandler};
|
||||
pub use user_joined_room::UserJoinedRoomHandler;
|
||||
pub use user_left_room::UserLeftRoomHandler;
|
||||
pub use wish_list_interval::WishListIntervalHandler;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! `GetUserStatus` (code 7) and `GetUserStats` (code 36): what the server
|
||||
//! knows about another user.
|
||||
//!
|
||||
//! The two answers arrive as separate messages, so a caller that wants a full
|
||||
//! picture asks for both and merges them — see `Client::request_user_info`.
|
||||
|
||||
use crate::actor::server_actor::ServerMessage;
|
||||
use crate::message::{Message, MessageHandler};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
/// Receives `GetUserStatus` (code 7): `username`, `status`, `privileged`.
|
||||
pub struct GetUserStatusHandler;
|
||||
|
||||
impl MessageHandler<ServerMessage> for GetUserStatusHandler {
|
||||
fn get_code(&self) -> u8 {
|
||||
7
|
||||
}
|
||||
|
||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||
let username = message.read_string();
|
||||
let status = message.read_int32();
|
||||
let privileged = message.read_bool();
|
||||
let _ = sender.send(ServerMessage::UserStatusReceived {
|
||||
username,
|
||||
status,
|
||||
privileged,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Receives `GetUserStats` (code 36): `username`, `average upload speed`, two
|
||||
/// obsolete fields, then the shared file and folder counts.
|
||||
pub struct GetUserStatsHandler;
|
||||
|
||||
impl MessageHandler<ServerMessage> for GetUserStatsHandler {
|
||||
fn get_code(&self) -> u8 {
|
||||
36
|
||||
}
|
||||
|
||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||
let username = message.read_string();
|
||||
let average_speed = message.read_int32();
|
||||
let _upload_number = message.read_int32(); // obsolete, always 0
|
||||
let _unknown = message.read_int32(); // obsolete, always 0
|
||||
let shared_files = message.read_int32();
|
||||
let shared_folders = message.read_int32();
|
||||
let _ = sender.send(ServerMessage::UserStatsReceived {
|
||||
username,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a payload the dispatcher would hand a handler: 8 bytes of frame
|
||||
/// header, then the fields, with the pointer parked at the payload.
|
||||
fn payload(build: impl FnOnce(&mut Message)) -> Message {
|
||||
let mut message = Message::new();
|
||||
message.write_raw_bytes(vec![0u8; 8]);
|
||||
build(&mut message);
|
||||
message.set_pointer(8);
|
||||
message
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_status_is_parsed_with_its_privileged_flag() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut message = payload(|m| {
|
||||
m.write_string("alice").write_int32(2).write_int8(1);
|
||||
});
|
||||
|
||||
GetUserStatusHandler.handle(&mut message, tx);
|
||||
match rx.try_recv() {
|
||||
Ok(ServerMessage::UserStatusReceived {
|
||||
username,
|
||||
status,
|
||||
privileged,
|
||||
}) => {
|
||||
assert_eq!(username, "alice");
|
||||
assert_eq!(status, 2);
|
||||
assert!(privileged);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_stats_skip_the_two_obsolete_fields() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
// username, speed, obsolete, obsolete, files, folders
|
||||
let mut message = payload(|m| {
|
||||
m.write_string("bob")
|
||||
.write_int32(1024)
|
||||
.write_int32(0)
|
||||
.write_int32(0)
|
||||
.write_int32(4321)
|
||||
.write_int32(77);
|
||||
});
|
||||
|
||||
GetUserStatsHandler.handle(&mut message, tx);
|
||||
match rx.try_recv() {
|
||||
Ok(ServerMessage::UserStatsReceived {
|
||||
username,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
}) => {
|
||||
assert_eq!(username, "bob");
|
||||
assert_eq!(average_speed, 1024);
|
||||
assert_eq!(shared_files, 4321, "must skip the obsolete pair");
|
||||
assert_eq!(shared_folders, 77);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,95 @@ pub enum DownloadStatus {
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Whether a user is reachable, as the server reports it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum UserStatus {
|
||||
#[default]
|
||||
Offline,
|
||||
Away,
|
||||
Online,
|
||||
}
|
||||
|
||||
impl UserStatus {
|
||||
/// Map the wire value (`GetUserStatus`, code 7). Anything unrecognised is
|
||||
/// treated as offline, which is the safe reading for "cannot be reached".
|
||||
#[must_use]
|
||||
pub const fn from_code(code: u32) -> Self {
|
||||
match code {
|
||||
1 => Self::Away,
|
||||
2 => Self::Online,
|
||||
_ => Self::Offline,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the user can be reached — online or away, but not offline.
|
||||
#[must_use]
|
||||
pub const fn is_reachable(self) -> bool {
|
||||
matches!(self, Self::Away | Self::Online)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Offline => "offline",
|
||||
Self::Away => "away",
|
||||
Self::Online => "online",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A user's presence, from `GetUserStatus`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UserPresence {
|
||||
pub status: UserStatus,
|
||||
/// Whether the server grants this user queue priority.
|
||||
pub privileged: bool,
|
||||
}
|
||||
|
||||
/// A user's sharing statistics, from `GetUserStats`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct UserStats {
|
||||
/// Average upload speed in bytes per second, as the server records it.
|
||||
pub average_speed: u32,
|
||||
pub shared_files: u32,
|
||||
pub shared_folders: u32,
|
||||
}
|
||||
|
||||
/// What the server knows about another user.
|
||||
///
|
||||
/// The server answers presence and statistics as two separate messages, so
|
||||
/// each part is `None` until its reply lands. A snapshot therefore cannot
|
||||
/// report a status the server never sent — ask for
|
||||
/// [`UserInfo::presence`] and handle `None` rather than reading a default.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct UserInfo {
|
||||
pub username: String,
|
||||
/// `None` until the status reply arrives.
|
||||
pub presence: Option<UserPresence>,
|
||||
/// `None` until the statistics reply arrives.
|
||||
pub stats: Option<UserStats>,
|
||||
}
|
||||
|
||||
impl UserInfo {
|
||||
/// An empty snapshot for `username`, before any reply has arrived.
|
||||
#[must_use]
|
||||
pub const fn pending(username: String) -> Self {
|
||||
Self {
|
||||
username,
|
||||
presence: None,
|
||||
stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True once both replies have landed.
|
||||
#[must_use]
|
||||
pub const fn is_complete(&self) -> bool {
|
||||
self.presence.is_some() && self.stats.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A public chat room advertised by the server (`RoomList`, code 64).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RoomInfo {
|
||||
|
||||
@@ -1711,3 +1711,85 @@ fn a_third_party_client_browses_a_large_share() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(share_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_reports_another_users_status_and_share_counts() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
// A sharer with a known number of files, so the statistics are checkable.
|
||||
let share_dir = unique_download_dir();
|
||||
std::fs::create_dir_all(share_dir.join("album")).unwrap();
|
||||
for name in ["one.flac", "two.flac"] {
|
||||
std::fs::write(share_dir.join("album").join(name), b"xxxx").unwrap();
|
||||
}
|
||||
let mut subject = Client::with_settings(ClientSettings {
|
||||
shared_directories: vec![share_dir.display().to_string()],
|
||||
..server.settings("e2e_info_subject", "pw")
|
||||
});
|
||||
subject.connect().expect("subject connect");
|
||||
assert!(subject.login().expect("subject login"));
|
||||
|
||||
let mut asker =
|
||||
Client::with_settings(server.settings("e2e_info_asker", "pw"));
|
||||
asker.connect().expect("asker connect");
|
||||
assert!(asker.login().expect("asker login"));
|
||||
|
||||
asker
|
||||
.request_user_info("e2e_info_subject")
|
||||
.expect("ask about the subject");
|
||||
|
||||
// Status and statistics arrive as two separate replies.
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
let mut info = None;
|
||||
while Instant::now() < deadline {
|
||||
match asker.user_info("e2e_info_subject") {
|
||||
Some(found) if found.is_complete() => {
|
||||
info = Some(found);
|
||||
break;
|
||||
}
|
||||
_ => std::thread::sleep(Duration::from_millis(100)),
|
||||
}
|
||||
}
|
||||
let info = info.expect("the server should answer with status and stats");
|
||||
|
||||
assert_eq!(info.username, "e2e_info_subject");
|
||||
let presence = info.presence.expect("presence should have arrived");
|
||||
assert!(
|
||||
presence.status.is_reachable(),
|
||||
"a logged-in user should not read as offline, got {}",
|
||||
presence.status
|
||||
);
|
||||
let stats = info.stats.expect("stats should have arrived");
|
||||
assert_eq!(stats.shared_files, 2, "the subject shares two files");
|
||||
assert!(stats.shared_folders >= 1, "and at least one folder");
|
||||
|
||||
let _ = std::fs::remove_dir_all(share_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_who_never_logged_in_reads_as_offline() {
|
||||
let server = server_or_skip!();
|
||||
let mut asker =
|
||||
Client::with_settings(server.settings("e2e_info_asker_b", "pw"));
|
||||
asker.connect().expect("asker connect");
|
||||
assert!(asker.login().expect("asker login"));
|
||||
|
||||
asker.request_user_info("nobody_by_this_name").expect("ask");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut info = None;
|
||||
while Instant::now() < deadline && info.is_none() {
|
||||
info = asker
|
||||
.user_info("nobody_by_this_name")
|
||||
.filter(|found| found.presence.is_some());
|
||||
if info.is_none() {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
let presence = info
|
||||
.expect("the server should answer for an unknown user")
|
||||
.presence
|
||||
.expect("with a presence");
|
||||
assert!(!presence.status.is_reachable(), "got {}", presence.status);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+48
-6
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user