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

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

New commands:

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

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

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

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

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
This commit is contained in:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent be6581ee6c
commit 03e8924a6b
21 changed files with 2187 additions and 19 deletions
@@ -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:?}"),
}
}
}