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