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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user