diff --git a/README.md b/README.md index 3f1f06c..ff1487e 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,19 @@ soulseek-rs room say # post to a room soulseek-rs room listen # stream room messages and joins soulseek-rs message send # send a private message soulseek-rs message read # stream incoming private messages +soulseek-rs room users # who is in a room +soulseek-rs serve [--follow] # stay online sharing, stream uploads +soulseek-rs whoami # confirm credentials and connection +soulseek-rs user # 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. diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 1d436b4..2cea4fa 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -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), + /// 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, @@ -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 diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index f27989e..bac458a 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -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), 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, /// Chat-room events awaiting consumption by the client/UI. room_events: Vec, + /// 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>, + /// What the server has told us about other users, merged across the + /// separate status and statistics replies. + user_info: HashMap, actor_system: Arc, } 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 { + 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 { + 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 { @@ -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() diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 38d820e..65277bf 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -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), diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index 609948b..41d4d5b 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -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 { + 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 { + 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 { diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index ea9bf27..4728b07 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -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, +}; diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index de50258..9137927 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -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 = [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 = [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; diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs index 181707f..d5e3baf 100644 --- a/soulseek-rs-lib/src/message/server/mod.rs +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -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; diff --git a/soulseek-rs-lib/src/message/server/user_info.rs b/soulseek-rs-lib/src/message/server/user_info.rs new file mode 100644 index 0000000..97ca290 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/user_info.rs @@ -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 for GetUserStatusHandler { + fn get_code(&self) -> u8 { + 7 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + 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 for GetUserStatsHandler { + fn get_code(&self) -> u8 { + 36 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + 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:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 03f0a23..e1b86c4 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -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, + /// `None` until the statistics reply arrives. + pub stats: Option, +} + +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 { diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 1891c56..03fcbaf 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -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); +} diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 81247c7..d89aed0 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -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 diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index 171909c..cce4ce3 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -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")) + } } } diff --git a/soulseek-rs/src/commands/peer.rs b/soulseek-rs/src/commands/peer.rs new file mode 100644 index 0000000..56f7abd --- /dev/null +++ b/soulseek-rs/src/commands/peer.rs @@ -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" + ); + } +} diff --git a/soulseek-rs/src/commands/settings.rs b/soulseek-rs/src/commands/settings.rs new file mode 100644 index 0000000..3a89b3a --- /dev/null +++ b/soulseek-rs/src/commands/settings.rs @@ -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, + 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 { + 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 { + 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(", ") + )) +} diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs index 531164a..435a137 100644 --- a/soulseek-rs/src/commands/social.rs +++ b/soulseek-rs/src/commands/social.rs @@ -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) -> CliResult { let session = Session::open(ctx)?; session.client.join_room(room).map_err(|e| { diff --git a/soulseek-rs/src/commands/transfer.rs b/soulseek-rs/src/commands/transfer.rs index 82f827f..e34bd4c 100644 --- a/soulseek-rs/src/commands/transfer.rs +++ b/soulseek-rs/src/commands/transfer.rs @@ -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) { diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 3042f15..18fc9f8 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -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, diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index 2c4d997..0a0e1f8 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -174,6 +174,9 @@ pub struct SearchRecord { pub bitrate: Option, pub duration: Option, 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, +} + +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, + pub privileged: Option, + pub average_speed: Option, + pub shared_files: Option, + pub shared_folders: Option, +} + +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, +} + +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, +} + +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"); } diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index 21f59b7..6bd2fe3 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -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 { + 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| { + *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 { @@ -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( + value: &str, + clear: bool, + key: &str, +) -> std::result::Result, String> { + if clear { + return Ok(None); + } + value + .parse::() + .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(); diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index 801ea28..de1a8ab 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -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 = 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 = 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![ + 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::(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); +}