feat!: wishlist, upload-queue privilege recognition, and search filters (#20)
Closes the last two gaps in Soulseek's list of features an alternative client must implement to be tolerated, and adds the result filters Nicotine+ has had all along. Wishlist: server code 103 is sent and code 104's announced interval is kept rather than logged and discarded, so the re-search timer runs off the schedule the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes persist in config.toml as a TOML array so a comma inside a query survives. `wish run` prints search-shaped records, so it pipes into `download --stdin`. Privilege recognition: the code-69 user list is parsed instead of counted and dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go through a queue with a slot cap (`--upload-slots`, default 2) that serves privileged users first — the cap is what makes the ordering mean anything, so they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it previously had no handler at all, and a departed peer's slot is released so a stalled peer cannot wedge uploads shut. Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside the existing `--min-bitrate` and `--free-slots`, in one flattened arg group shared by `search`, `get` and `wish run`. Also serialises the library e2e suite behind the same gate `cli_e2e` already uses: the new queue tests hold peer sockets open on purpose, which starved concurrently-spawned soulfinds and failed unrelated download tests with `login: Timeout`. BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive match on it no longer compiles, and `cli::Commands` gains `Wish`.
This commit is contained in:
@@ -45,9 +45,12 @@ Each entry lists the keys of the objects it prints.
|
||||
|
||||
**`search <query>`** — one object per matching file:
|
||||
`user`, `path`, `size`, `bitrate`, `duration`, `free_slot`, `slots`, `speed`.
|
||||
Flags: `--min-bitrate <kbps>`, `--free-slots`, `-n/--limit` (0 means all),
|
||||
`--sort best|size|bitrate|speed`. Collects results for the whole
|
||||
`--search-timeout` window before printing anything.
|
||||
Filters: `--min-bitrate <kbps>`, `--free-slots`, `--exclude <term>` (repeatable),
|
||||
`--extension <ext>` (repeatable, dot optional), `--min-size <bytes>`,
|
||||
`--max-size <bytes>`. Also `-n/--limit` (0 means all) and
|
||||
`--sort best|size|bitrate|speed`. Every filter given has to be satisfied.
|
||||
Collects results for the whole `--search-timeout` window before printing
|
||||
anything.
|
||||
|
||||
**`download <user> <path> [--size N]`** or **`download --stdin`** — one object
|
||||
per finished transfer: `user`, `path`, `size`, `file` (the local path written).
|
||||
@@ -56,9 +59,20 @@ per finished transfer: `user`, `path`, `size`, `file` (the local path written).
|
||||
starting over, so retrying a failed download is cheap.
|
||||
|
||||
**`get <query>`** — search, pick, and download in one step. `--pick
|
||||
best|first|all`, `-n/--limit`, `--min-bitrate`, `--free-slots`. Prefer this
|
||||
best|first|all`, `-n/--limit`, plus every filter `search` accepts. Prefer this
|
||||
over `search` + `download` when the user just wants the file.
|
||||
|
||||
**`wish list`** — one object per standing search: `query`.
|
||||
**`wish add <query>`** and **`wish remove <query>`** — change the wishlist in
|
||||
the config file. `add` prints the stored `query`; `remove` prints nothing. A
|
||||
repeat of an existing wish is not an error.
|
||||
**`wish run`** — search every stored wish once and print `search`-shaped
|
||||
objects, so the output pipes straight into `download --stdin`. `--only <query>`
|
||||
runs one stored wish (it must already be on the list), and every `search` filter
|
||||
applies. Exit 4 when no wish matched anything. Use this when the user wants
|
||||
something that is not on the network right now: add it as a wish, then run the
|
||||
wishlist later rather than repeating the same search.
|
||||
|
||||
**`browse <user>`** — one object per file the peer shares: `user`, `directory`,
|
||||
`path`, `size`.
|
||||
|
||||
@@ -68,8 +82,10 @@ be `null` when the server has not answered that half — `null` means unknown,
|
||||
not offline.
|
||||
|
||||
**`whoami`** — one object: `user`, `server`, `listening`, `listen_port`,
|
||||
`shared_folders`, `shared_files`, `download_dir`. The cheapest way to prove the
|
||||
credentials and connection work.
|
||||
`shared_folders`, `shared_files`, `download_dir`, `privilege_seconds`. The
|
||||
cheapest way to prove the credentials and connection work.
|
||||
`privilege_seconds` is 0 for an ordinary account and `null` only when the
|
||||
server did not answer — those are different facts, so do not treat null as 0.
|
||||
|
||||
**`room list`** — `room`, `users`.
|
||||
**`room users <room>`** — `room`, `user`.
|
||||
@@ -81,7 +97,13 @@ credentials and connection work.
|
||||
|
||||
**`serve`** — stay online sharing files, one object per upload change: `user`,
|
||||
`path`, `size`, `bytes_sent`, `speed`, `status`
|
||||
(`uploading`/`completed`/`cancelled`/`failed`), `reason`.
|
||||
(`queued`/`uploading`/`completed`/`cancelled`/`failed`), `reason`. A `queued`
|
||||
record's `reason` is its place in the queue. `--upload-slots N` sets how many
|
||||
uploads run at once (2 by default); the rest wait, with users the server lists
|
||||
as privileged served first. With `--wishlist` it also re-runs the wishlist on
|
||||
the interval the server sets and prints `search`-shaped objects for what it
|
||||
finds, so one long-running process covers both halves of being a good
|
||||
participant.
|
||||
|
||||
**`shares list`** — one object per configured folder: `directory`, `usable`.
|
||||
**`shares add <directory>`** and **`shares remove <directory>`** — change the
|
||||
|
||||
+83
-12
@@ -168,6 +168,10 @@ pub enum Commands {
|
||||
/// List the files a user shares
|
||||
Browse(BrowseArgs),
|
||||
|
||||
/// Standing searches the server re-runs on its own schedule
|
||||
#[command(subcommand)]
|
||||
Wish(WishCommand),
|
||||
|
||||
/// Chat rooms
|
||||
#[command(subcommand)]
|
||||
Room(RoomCommand),
|
||||
@@ -265,6 +269,15 @@ pub struct ServeArgs {
|
||||
/// Stay online until interrupted, ignoring --duration
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub follow: bool,
|
||||
|
||||
/// Also re-run the wishlist on the interval the server sets, reporting
|
||||
/// matches as they arrive
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub wishlist: bool,
|
||||
|
||||
/// How many uploads to run at once; the rest queue, privileged users first
|
||||
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..=64))]
|
||||
pub upload_slots: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
@@ -353,11 +366,10 @@ pub enum Pick {
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct SearchArgs {
|
||||
/// What to search for
|
||||
pub query: String,
|
||||
|
||||
/// The result restrictions every command that searches accepts, so `search`,
|
||||
/// `get` and `wish run` all narrow a result set the same way.
|
||||
#[derive(Args, Debug, Default, Clone)]
|
||||
pub struct FilterArgs {
|
||||
/// Drop results below this bitrate in kbps
|
||||
#[arg(long, value_name = "KBPS")]
|
||||
pub min_bitrate: Option<u32>,
|
||||
@@ -366,6 +378,31 @@ pub struct SearchArgs {
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub free_slots: bool,
|
||||
|
||||
/// Drop results whose path contains this term; repeat for several
|
||||
#[arg(long, value_name = "TERM", action = ArgAction::Append)]
|
||||
pub exclude: Vec<String>,
|
||||
|
||||
/// Keep only this file extension, with or without a dot; repeat for several
|
||||
#[arg(long, value_name = "EXT", action = ArgAction::Append)]
|
||||
pub extension: Vec<String>,
|
||||
|
||||
/// Drop results smaller than this many bytes
|
||||
#[arg(long, value_name = "BYTES")]
|
||||
pub min_size: Option<u64>,
|
||||
|
||||
/// Drop results larger than this many bytes
|
||||
#[arg(long, value_name = "BYTES")]
|
||||
pub max_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct SearchArgs {
|
||||
/// What to search for
|
||||
pub query: String,
|
||||
|
||||
#[command(flatten)]
|
||||
pub filter: FilterArgs,
|
||||
|
||||
/// Print at most this many results (0 means all)
|
||||
#[arg(short = 'n', long, default_value_t = 0)]
|
||||
pub limit: usize,
|
||||
@@ -417,13 +454,8 @@ pub struct GetArgs {
|
||||
#[arg(short = 'n', long, default_value_t = 0)]
|
||||
pub limit: usize,
|
||||
|
||||
/// Drop results below this bitrate in kbps
|
||||
#[arg(long, value_name = "KBPS")]
|
||||
pub min_bitrate: Option<u32>,
|
||||
|
||||
/// Keep only peers advertising a free upload slot
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub free_slots: bool,
|
||||
#[command(flatten)]
|
||||
pub filter: FilterArgs,
|
||||
|
||||
/// Seconds to wait for each transfer before giving up
|
||||
#[arg(
|
||||
@@ -450,6 +482,45 @@ pub struct BrowseArgs {
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum WishCommand {
|
||||
/// Store a query and search for it from now on
|
||||
Add {
|
||||
/// What to keep looking for
|
||||
query: String,
|
||||
},
|
||||
|
||||
/// Stop looking for a query
|
||||
Remove {
|
||||
/// The wish to drop, as shown by `wish list`
|
||||
query: String,
|
||||
},
|
||||
|
||||
/// Print the stored queries
|
||||
List,
|
||||
|
||||
/// Search every stored query once and print what came back
|
||||
Run(WishRunArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct WishRunArgs {
|
||||
/// Only run this wish instead of all of them
|
||||
#[arg(long, value_name = "QUERY")]
|
||||
pub only: Option<String>,
|
||||
|
||||
#[command(flatten)]
|
||||
pub filter: FilterArgs,
|
||||
|
||||
/// Print at most this many results per wish (0 means all)
|
||||
#[arg(short = 'n', long, default_value_t = 0)]
|
||||
pub limit: usize,
|
||||
|
||||
/// Result order
|
||||
#[arg(long, value_enum, default_value_t = SortKey::Best)]
|
||||
pub sort: SortKey,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum RoomCommand {
|
||||
/// List public rooms and their user counts
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod social;
|
||||
pub mod transfer;
|
||||
pub mod wish;
|
||||
|
||||
use crate::cli::{Commands, MessageCommand, RoomCommand};
|
||||
use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord};
|
||||
@@ -191,13 +192,16 @@ pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
|
||||
Commands::Message(MessageCommand::Read { duration, follow }) => {
|
||||
social::message_read(ctx, listen_span(duration, follow))
|
||||
}
|
||||
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(_)
|
||||
// These need the config file as well as a session, so main.rs runs
|
||||
// them where the store is in scope.
|
||||
Commands::Serve(_)
|
||||
| Commands::Wish(_)
|
||||
| Commands::Config(_)
|
||||
| Commands::Shares(_)
|
||||
| Commands::Skills(_)
|
||||
| Commands::Completions(_) => {
|
||||
|
||||
@@ -12,6 +12,11 @@ use std::time::{Duration, Instant};
|
||||
/// How often the serve loop samples the upload table.
|
||||
const POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long `whoami` waits for the server's privilege answer. It is one small
|
||||
/// message on an already-open connection, so a server that has not replied by
|
||||
/// now is not going to.
|
||||
const PRIVILEGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
pub fn whoami(ctx: &Ctx) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
let (folders, files) = session.client.shared_counts();
|
||||
@@ -23,10 +28,29 @@ pub fn whoami(ctx: &Ctx) -> CliResult {
|
||||
shared_folders: folders,
|
||||
shared_files: files,
|
||||
download_dir: ctx.download_dir.clone(),
|
||||
privilege_seconds: own_privileges(&session.client),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ask the server how much privilege time this account has left.
|
||||
///
|
||||
/// A server that does not answer leaves the field null rather than reporting a
|
||||
/// zero, because "no privileges" and "did not say" are different facts.
|
||||
fn own_privileges(client: &soulseek_rs::Client) -> Option<u32> {
|
||||
if client.check_privileges().is_err() {
|
||||
return None;
|
||||
}
|
||||
let deadline = Instant::now() + PRIVILEGE_TIMEOUT;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(seconds) = client.own_privilege_seconds() {
|
||||
return Some(seconds);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult {
|
||||
let session = Session::open(ctx)?;
|
||||
session.client.request_user_info(&args.user).map_err(|e| {
|
||||
@@ -69,7 +93,13 @@ pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
|
||||
/// Stay online sharing files, and — with `--wishlist` — re-run the standing
|
||||
/// searches on the interval the server sets while doing so.
|
||||
pub fn serve(
|
||||
ctx: &Ctx,
|
||||
args: &ServeArgs,
|
||||
mut sweeper: Option<crate::commands::wish::Sweeper>,
|
||||
) -> CliResult {
|
||||
if ctx.settings.shared_directories.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"nothing to share: pass --shared-dir, or add one with `shares add`",
|
||||
@@ -82,6 +112,10 @@ pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
|
||||
}
|
||||
|
||||
let session = Session::open(ctx)?;
|
||||
if let Some(slots) = args.upload_slots {
|
||||
session.client.set_upload_slots(usize::from(slots));
|
||||
ctx.out.status(&format!("{slots} upload slots"));
|
||||
}
|
||||
let (folders, files) = session.client.shared_counts();
|
||||
ctx.out.status(&format!(
|
||||
"serving {files} files in {folders} folders on port {}",
|
||||
@@ -109,6 +143,9 @@ pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
|
||||
reported.insert(key, record);
|
||||
}
|
||||
}
|
||||
if let Some(sweeper) = sweeper.as_mut() {
|
||||
sweeper.tick(ctx, &session.client);
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
Ok(())
|
||||
@@ -117,6 +154,9 @@ pub fn serve(ctx: &Ctx, args: &ServeArgs) -> CliResult {
|
||||
/// Flatten a live upload into the record `serve` reports.
|
||||
fn upload_record(upload: &UploadInfo) -> UploadRecord {
|
||||
let (status, reason) = match &upload.status {
|
||||
UploadStatus::Queued(place) => {
|
||||
("queued", Some(format!("place {place}")))
|
||||
}
|
||||
UploadStatus::InProgress => ("uploading", None),
|
||||
UploadStatus::Completed => ("completed", None),
|
||||
UploadStatus::Cancelled => ("cancelled", None),
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct Store {
|
||||
}
|
||||
|
||||
impl Store {
|
||||
fn save(&self, config: &FileConfig) -> CliResult {
|
||||
pub 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)",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Finding files and fetching them: `search`, `download`, and `get`.
|
||||
|
||||
use super::{Ctx, Session, social};
|
||||
use crate::cli::{DownloadArgs, GetArgs, Pick, SearchArgs, SortKey};
|
||||
use crate::cli::{
|
||||
DownloadArgs, FilterArgs, GetArgs, Pick, SearchArgs, SortKey,
|
||||
};
|
||||
use crate::output::{
|
||||
CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord,
|
||||
};
|
||||
@@ -19,25 +21,69 @@ use std::time::{Duration, Instant};
|
||||
const ATTR_BITRATE: u32 = 0;
|
||||
const ATTR_DURATION: u32 = 1;
|
||||
|
||||
/// What to keep out of the raw result set.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
/// What to keep out of the raw result set. Every field is a further
|
||||
/// restriction: a candidate has to satisfy all of them.
|
||||
///
|
||||
/// `exclude` and `extensions` are stored already lowercased and without leading
|
||||
/// dots — see [`Filter::from`] — so matching a result costs no allocation. A
|
||||
/// search returns thousands of files and every one of them is tested.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Filter {
|
||||
pub min_bitrate: Option<u32>,
|
||||
pub free_slots_only: bool,
|
||||
/// Terms that must not appear anywhere in the remote path, lowercased.
|
||||
pub exclude: Vec<String>,
|
||||
/// File extensions to keep, lowercased and dotless.
|
||||
pub extensions: Vec<String>,
|
||||
pub min_size: Option<u64>,
|
||||
pub max_size: Option<u64>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
const fn keeps(&self, candidate: &SearchRecord) -> bool {
|
||||
fn keeps(&self, candidate: &SearchRecord) -> bool {
|
||||
if self.free_slots_only && !candidate.free_slot {
|
||||
return false;
|
||||
}
|
||||
match (self.min_bitrate, candidate.bitrate) {
|
||||
(Some(minimum), Some(bitrate)) => bitrate >= minimum,
|
||||
// An unknown bitrate cannot satisfy a bitrate floor.
|
||||
(Some(_), None) => false,
|
||||
(None, _) => true,
|
||||
if self.min_size.is_some_and(|min| candidate.size < min) {
|
||||
return false;
|
||||
}
|
||||
if self.max_size.is_some_and(|max| candidate.size > max) {
|
||||
return false;
|
||||
}
|
||||
// An unknown bitrate cannot satisfy a bitrate floor.
|
||||
if self
|
||||
.min_bitrate
|
||||
.is_some_and(|min| candidate.bitrate.is_none_or(|have| have < min))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.exclude.is_empty() && self.extensions.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let path = candidate.path.to_lowercase();
|
||||
!self.exclude.iter().any(|term| path.contains(term.as_str()))
|
||||
&& (self.extensions.is_empty() || self.matches_extension(&path))
|
||||
}
|
||||
|
||||
fn matches_extension(&self, lowercase_path: &str) -> bool {
|
||||
let name = lowercase_path
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let Some((_, actual)) = name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
self.extensions.iter().any(|wanted| wanted == actual)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase and strip a leading dot, so the comparison in [`Filter::keeps`] is
|
||||
/// a plain equality however the flag was spelled.
|
||||
fn normalized(terms: &[String]) -> Vec<String> {
|
||||
terms
|
||||
.iter()
|
||||
.map(|term| term.trim_start_matches('.').to_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Flatten per-peer results into individual files, dropping what `filter`
|
||||
@@ -112,6 +158,19 @@ pub struct Request {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
impl From<&FilterArgs> for Filter {
|
||||
fn from(args: &FilterArgs) -> Self {
|
||||
Self {
|
||||
min_bitrate: args.min_bitrate,
|
||||
free_slots_only: args.free_slots,
|
||||
exclude: normalized(&args.exclude),
|
||||
extensions: normalized(&args.extension),
|
||||
min_size: args.min_size,
|
||||
max_size: args.max_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SearchRecord> for Request {
|
||||
fn from(candidate: &SearchRecord) -> Self {
|
||||
Self {
|
||||
@@ -123,7 +182,7 @@ impl From<&SearchRecord> for Request {
|
||||
}
|
||||
|
||||
/// Filter, rank, and cap a raw result set, failing when nothing survives.
|
||||
fn ranked(
|
||||
pub(super) fn ranked(
|
||||
results: &[SearchResult],
|
||||
filter: &Filter,
|
||||
sort: SortKey,
|
||||
@@ -146,10 +205,7 @@ pub fn search(ctx: &Ctx, args: &SearchArgs) -> CliResult {
|
||||
let results = collect(ctx, &session.client, &args.query, false)?;
|
||||
let found = ranked(
|
||||
&results,
|
||||
&Filter {
|
||||
min_bitrate: args.min_bitrate,
|
||||
free_slots_only: args.free_slots,
|
||||
},
|
||||
&Filter::from(&args.filter),
|
||||
args.sort,
|
||||
args.limit,
|
||||
&args.query,
|
||||
@@ -206,10 +262,7 @@ pub fn get(ctx: &Ctx, args: &GetArgs) -> CliResult {
|
||||
};
|
||||
let found = ranked(
|
||||
&results,
|
||||
&Filter {
|
||||
min_bitrate: args.min_bitrate,
|
||||
free_slots_only: args.free_slots,
|
||||
},
|
||||
&Filter::from(&args.filter),
|
||||
SortKey::Best,
|
||||
limit,
|
||||
&args.query,
|
||||
@@ -634,6 +687,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn paths_of(found: &[SearchRecord]) -> Vec<String> {
|
||||
found.iter().map(|c| c.path.clone()).collect()
|
||||
}
|
||||
|
||||
fn sample() -> Vec<SearchResult> {
|
||||
vec![
|
||||
result("slow", 1, 100, vec![file("a\\low.mp3", 10, Some(128))]),
|
||||
@@ -667,28 +724,164 @@ mod tests {
|
||||
assert_eq!(best.speed, 900);
|
||||
}
|
||||
|
||||
/// Build a filter the way a command does, so the tests cover the flag
|
||||
/// normalisation (case, leading dots) rather than assuming it.
|
||||
fn filter(build: impl FnOnce(&mut FilterArgs)) -> Filter {
|
||||
let mut args = FilterArgs::default();
|
||||
build(&mut args);
|
||||
Filter::from(&args)
|
||||
}
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|v| (*v).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_bitrate_drops_lower_and_unknown_bitrates() {
|
||||
let filter = Filter {
|
||||
min_bitrate: Some(256),
|
||||
free_slots_only: false,
|
||||
};
|
||||
let found = candidates(&sample(), &filter);
|
||||
let paths: Vec<&str> = found.iter().map(|c| c.path.as_str()).collect();
|
||||
assert_eq!(paths, ["b\\best.mp3", "c\\good.mp3"]);
|
||||
let filter = filter(|a| a.min_bitrate = Some(256));
|
||||
assert_eq!(
|
||||
paths_of(&candidates(&sample(), &filter)),
|
||||
["b\\best.mp3", "c\\good.mp3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_slots_only_keeps_peers_advertising_a_slot() {
|
||||
let filter = Filter {
|
||||
min_bitrate: None,
|
||||
free_slots_only: true,
|
||||
};
|
||||
let filter = filter(|a| a.free_slots = true);
|
||||
let found = candidates(&sample(), &filter);
|
||||
assert!(found.iter().all(|c| c.free_slot));
|
||||
assert!(found.iter().all(|c| c.user != "busy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_drops_a_path_containing_the_term_whatever_its_case() {
|
||||
let filter = filter(|a| a.exclude = strings(&["LOW"]));
|
||||
let found = candidates(&sample(), &filter);
|
||||
assert!(found.iter().all(|c| c.path != "a\\low.mp3"));
|
||||
assert_eq!(found.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_matches_any_term_not_all_of_them() {
|
||||
let filter = filter(|a| a.exclude = strings(&["low", "unknown"]));
|
||||
assert_eq!(
|
||||
paths_of(&candidates(&sample(), &filter)),
|
||||
["b\\best.mp3", "c\\good.mp3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_keeps_only_that_type_however_it_is_written() {
|
||||
let mixed = vec![result(
|
||||
"peer",
|
||||
1,
|
||||
1,
|
||||
vec![
|
||||
file("a.mp3", 1, None),
|
||||
file("b.FLAC", 2, None),
|
||||
file("c.ogg", 3, None),
|
||||
],
|
||||
)];
|
||||
for spelling in [".flac", "flac", "FLAC", ".FLAC"] {
|
||||
let filter = filter(|a| a.extension = strings(&[spelling]));
|
||||
assert_eq!(
|
||||
paths_of(&candidates(&mixed, &filter)),
|
||||
["b.FLAC"],
|
||||
"--extension {spelling} should match b.FLAC"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_extensions_are_a_union() {
|
||||
let mixed = vec![result(
|
||||
"peer",
|
||||
1,
|
||||
1,
|
||||
vec![
|
||||
file("a.mp3", 1, None),
|
||||
file("b.flac", 2, None),
|
||||
file("c.ogg", 3, None),
|
||||
],
|
||||
)];
|
||||
let filter = filter(|a| a.extension = strings(&["mp3", "ogg"]));
|
||||
assert_eq!(paths_of(&candidates(&mixed, &filter)), ["a.mp3", "c.ogg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extensionless_file_never_satisfies_an_extension_filter() {
|
||||
let odd = vec![result("peer", 1, 1, vec![file("README", 1, None)])];
|
||||
let filter = filter(|a| a.extension = strings(&["mp3"]));
|
||||
assert!(candidates(&odd, &filter).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dot_in_a_folder_name_is_not_the_files_extension() {
|
||||
// Only the last path segment decides the extension, or every file under
|
||||
// "Album (2004).v0" would look like a .v0 file.
|
||||
let nested = vec![result(
|
||||
"peer",
|
||||
1,
|
||||
1,
|
||||
vec![file("Album (2004).v0\\track.mp3", 1, None)],
|
||||
)];
|
||||
assert!(
|
||||
candidates(&nested, &filter(|a| a.extension = strings(&["v0"])))
|
||||
.is_empty(),
|
||||
"the folder's .v0 suffix must not count as the file's extension"
|
||||
);
|
||||
assert_eq!(
|
||||
paths_of(&candidates(
|
||||
&nested,
|
||||
&filter(|a| a.extension = strings(&["mp3"]))
|
||||
)),
|
||||
["Album (2004).v0\\track.mp3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_bounds_are_inclusive_on_both_ends() {
|
||||
let filter = filter(|a| {
|
||||
a.min_size = Some(50);
|
||||
a.max_size = Some(70);
|
||||
});
|
||||
assert_eq!(
|
||||
paths_of(&candidates(&sample(), &filter)),
|
||||
["c\\good.mp3", "c\\unknown.mp3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_size_bound_works_without_the_other() {
|
||||
let floor = filter(|a| a.min_size = Some(70));
|
||||
assert_eq!(
|
||||
paths_of(&candidates(&sample(), &floor)),
|
||||
["b\\best.mp3", "c\\unknown.mp3"]
|
||||
);
|
||||
let ceiling = filter(|a| a.max_size = Some(10));
|
||||
assert_eq!(paths_of(&candidates(&sample(), &ceiling)), ["a\\low.mp3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_combine_as_an_and() {
|
||||
let filter = filter(|a| {
|
||||
a.free_slots = true;
|
||||
a.min_bitrate = Some(200);
|
||||
a.min_size = Some(20);
|
||||
a.exclude = strings(&["bad"]);
|
||||
a.extension = strings(&["mp3"]);
|
||||
});
|
||||
assert_eq!(paths_of(&candidates(&sample(), &filter)), ["c\\good.mp3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_that_rejects_everything_is_the_no_results_verdict() {
|
||||
let filter = filter(|a| a.min_size = Some(u64::MAX));
|
||||
let error = ranked(&sample(), &filter, SortKey::Best, 0, "q")
|
||||
.expect_err("nothing survives the filter");
|
||||
assert_eq!(error.exit, Exit::NoResults);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_prefers_a_free_slot_over_a_higher_bitrate() {
|
||||
let mut found = candidates(&sample(), &Filter::default());
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Standing searches: `wish`.
|
||||
//!
|
||||
//! A wishlist is what makes a client keep looking for something it did not find
|
||||
//! today. The server treats it as a distinct message (code 103) and rate-limits
|
||||
//! it to the interval it announces at login, so the schedule is the server's to
|
||||
//! set — we only decide which queries to send and what to print.
|
||||
|
||||
use super::{Ctx, Session, settings::Store, transfer};
|
||||
use crate::cli::{SortKey, WishRunArgs};
|
||||
use crate::output::{CliError, CliResult, Out, WishRecord};
|
||||
use crate::persist::config::FileConfig;
|
||||
use soulseek_rs::Client;
|
||||
use std::time::Instant;
|
||||
|
||||
pub fn add(out: &Out, store: &Store, query: &str) -> CliResult {
|
||||
let mut updated = store.config.clone();
|
||||
if !updated.add_wish(query) {
|
||||
if query.trim().is_empty() {
|
||||
return Err(CliError::usage("a wish needs a query"));
|
||||
}
|
||||
out.status(&format!("already wishing for '{}'", query.trim()));
|
||||
return Ok(());
|
||||
}
|
||||
store.save(&updated)?;
|
||||
out.emit(&WishRecord {
|
||||
query: query.trim().to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(out: &Out, store: &Store, query: &str) -> CliResult {
|
||||
let mut updated = store.config.clone();
|
||||
if !updated.remove_wish(query) {
|
||||
return Err(CliError::no_results(format!(
|
||||
"'{}' is not on the wishlist",
|
||||
query.trim()
|
||||
)));
|
||||
}
|
||||
store.save(&updated)?;
|
||||
out.status(&format!("no longer wishing for '{}'", query.trim()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list(out: &Out, store: &Store) -> CliResult {
|
||||
let wishes = store.config.wishes();
|
||||
if wishes.is_empty() {
|
||||
return Err(CliError::no_results("the wishlist is empty"));
|
||||
}
|
||||
for query in wishes {
|
||||
out.emit(&WishRecord { query });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(ctx: &Ctx, store: &Store, args: &WishRunArgs) -> CliResult {
|
||||
let wishes = selected(&store.config, args.only.as_deref())?;
|
||||
let session = Session::open(ctx)?;
|
||||
|
||||
if !sweep(ctx, &session.client, &wishes, &Sweep::from(args)) {
|
||||
return Err(CliError::no_results(
|
||||
"no wish matched anything on the network",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How to narrow and order what a sweep prints — the parts of `wish run`'s flags
|
||||
/// that outlive the command, so `serve --wishlist` can sweep without inventing
|
||||
/// a set of command-line arguments it never received.
|
||||
struct Sweep {
|
||||
filter: transfer::Filter,
|
||||
sort: SortKey,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl Default for Sweep {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filter: transfer::Filter::default(),
|
||||
sort: SortKey::Best,
|
||||
limit: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&WishRunArgs> for Sweep {
|
||||
fn from(args: &WishRunArgs) -> Self {
|
||||
Self {
|
||||
filter: transfer::Filter::from(&args.filter),
|
||||
sort: args.sort,
|
||||
limit: args.limit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run the wishlist for as long as `serve` stays online, on the interval the
|
||||
/// server set. Called from the serve loop rather than owning it, so uploads keep
|
||||
/// being reported between sweeps.
|
||||
pub struct Sweeper {
|
||||
wishes: Vec<String>,
|
||||
next: Instant,
|
||||
}
|
||||
|
||||
impl Sweeper {
|
||||
/// `None` when there is nothing to look for, so `serve` can say so once
|
||||
/// instead of waking up forever with an empty list.
|
||||
#[must_use]
|
||||
pub fn new(wishes: Vec<String>) -> Option<Self> {
|
||||
(!wishes.is_empty()).then(|| Self {
|
||||
wishes,
|
||||
// The first sweep is immediate: a wishlist that waits twelve
|
||||
// minutes before its first look is indistinguishable from broken.
|
||||
next: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Search every wish if the interval has elapsed. Does nothing otherwise,
|
||||
/// so the caller can poll it as often as it likes.
|
||||
///
|
||||
/// this blocks the serve loop for one search window, so uploads go
|
||||
/// unreported and `--duration` overshoots by that much. Move the sweep to its
|
||||
/// own thread with a channel of records if that ever matters; what mattered
|
||||
/// was that it used to be one window *per wish*.
|
||||
pub fn tick(&mut self, ctx: &Ctx, client: &Client) {
|
||||
if Instant::now() < self.next {
|
||||
return;
|
||||
}
|
||||
sweep(ctx, client, &self.wishes, &Sweep::default());
|
||||
let interval = client.wishlist_interval();
|
||||
self.next = Instant::now() + interval;
|
||||
ctx.out
|
||||
.status(&format!("next wishlist sweep in {}s", interval.as_secs()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Search every wish and print what survived the filters. Returns whether
|
||||
/// anything did.
|
||||
///
|
||||
/// All the searches go out before anything is waited for: peers answer whenever
|
||||
/// they get round to it, so one shared window covers every wish, where waiting
|
||||
/// per wish would cost a full window each.
|
||||
fn sweep(ctx: &Ctx, client: &Client, wishes: &[String], how: &Sweep) -> bool {
|
||||
let mut sent = Vec::new();
|
||||
for wish in wishes {
|
||||
match client.start_wishlist_search(wish) {
|
||||
Ok(()) => sent.push(wish),
|
||||
Err(e) => ctx
|
||||
.out
|
||||
.warn(&format!("'{wish}' could not be searched: {e}")),
|
||||
}
|
||||
}
|
||||
if sent.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.out.status(&format!(
|
||||
"wishing for {} ({}s)…",
|
||||
sent.iter()
|
||||
.map(|w| format!("'{w}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
ctx.search_timeout.as_secs()
|
||||
));
|
||||
Client::collect_for(ctx.search_timeout, None);
|
||||
|
||||
let mut found_any = false;
|
||||
for wish in sent {
|
||||
let results = client.get_search_results(wish);
|
||||
let ranked =
|
||||
transfer::ranked(&results, &how.filter, how.sort, how.limit, wish);
|
||||
let Ok(found) = ranked else {
|
||||
ctx.out.status(&format!("nothing for '{wish}' this time"));
|
||||
continue;
|
||||
};
|
||||
found_any = true;
|
||||
for candidate in &found {
|
||||
ctx.out.emit(candidate);
|
||||
}
|
||||
}
|
||||
found_any
|
||||
}
|
||||
|
||||
/// Which wishes a run covers. `--only` has to name a stored wish: letting a
|
||||
/// typo through would quietly search for something the user never asked to
|
||||
/// keep, and report it as a wishlist hit.
|
||||
///
|
||||
/// Matching goes through [`FileConfig::wish`], so a query that `wish add` would
|
||||
/// call a duplicate is one `--only` can select.
|
||||
fn selected(config: &FileConfig, only: Option<&str>) -> CliResult<Vec<String>> {
|
||||
let Some(only) = only else {
|
||||
let wishes = config.wishes();
|
||||
if wishes.is_empty() {
|
||||
return Err(CliError::no_results(
|
||||
"the wishlist is empty — add one with `wish add QUERY`",
|
||||
));
|
||||
}
|
||||
return Ok(wishes);
|
||||
};
|
||||
|
||||
config
|
||||
.wish(only)
|
||||
.map(|wish| vec![wish.clone()])
|
||||
.ok_or_else(|| {
|
||||
CliError::usage(format!(
|
||||
"'{}' is not on the wishlist; `wish list` shows what is",
|
||||
only.trim()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::output::Exit;
|
||||
|
||||
fn stored() -> FileConfig {
|
||||
let mut config = FileConfig::default();
|
||||
config.add_wish("Aphex Twin");
|
||||
config.add_wish("Boards of Canada");
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_only_every_stored_wish_runs() {
|
||||
assert_eq!(selected(&stored(), None).unwrap(), stored().wishes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_wishlist_is_the_no_results_verdict() {
|
||||
let error =
|
||||
selected(&FileConfig::default(), None).expect_err("nothing to run");
|
||||
assert_eq!(error.exit, Exit::NoResults);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_selects_one_stored_wish_ignoring_case_and_space() {
|
||||
assert_eq!(
|
||||
selected(&stored(), Some(" aphex twin ")).unwrap(),
|
||||
["Aphex Twin"],
|
||||
"the stored spelling is what gets searched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_naming_something_unstored_is_a_usage_error() {
|
||||
let error = selected(&stored(), Some("plaid"))
|
||||
.expect_err("a typo must not become an ad-hoc search");
|
||||
assert_eq!(error.exit, Exit::Usage);
|
||||
assert!(error.message.contains("wish list"), "{}", error.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sweeper_needs_something_to_look_for() {
|
||||
assert!(Sweeper::new(Vec::new()).is_none());
|
||||
assert!(Sweeper::new(stored().wishes()).is_some());
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -8,7 +8,10 @@ mod port_mapping;
|
||||
mod ui;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands, ConfigCommand, SharesCommand, parse_server_address};
|
||||
use cli::{
|
||||
Cli, Commands, ConfigCommand, SharesCommand, WishCommand,
|
||||
parse_server_address,
|
||||
};
|
||||
use commands::Ctx;
|
||||
use output::{CliError, CliResult, Exit, Out};
|
||||
use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress};
|
||||
@@ -93,6 +96,15 @@ fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
Commands::Shares(SharesCommand::Remove { ref directory }) => {
|
||||
return commands::settings::shares_remove(out, &store, directory);
|
||||
}
|
||||
Commands::Wish(WishCommand::Add { ref query }) => {
|
||||
return commands::wish::add(out, &store, query);
|
||||
}
|
||||
Commands::Wish(WishCommand::Remove { ref query }) => {
|
||||
return commands::wish::remove(out, &store, query);
|
||||
}
|
||||
Commands::Wish(WishCommand::List) => {
|
||||
return commands::wish::list(out, &store);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -104,6 +116,22 @@ fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
Commands::Shares(SharesCommand::Reindex) => {
|
||||
commands::settings::shares_reindex(&ctx)
|
||||
}
|
||||
Commands::Wish(WishCommand::Run(ref args)) => {
|
||||
commands::wish::run(&ctx, &store, args)
|
||||
}
|
||||
Commands::Serve(ref args) => {
|
||||
let sweeper = args
|
||||
.wishlist
|
||||
.then(|| commands::wish::Sweeper::new(store.config.wishes()))
|
||||
.flatten();
|
||||
if args.wishlist && sweeper.is_none() {
|
||||
out.warn(
|
||||
"--wishlist has nothing to look for; add one with \
|
||||
`wish add QUERY`",
|
||||
);
|
||||
}
|
||||
commands::peer::serve(&ctx, args, sweeper)
|
||||
}
|
||||
other => commands::run(&ctx, other),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +319,9 @@ pub struct WhoamiRecord {
|
||||
pub shared_folders: u32,
|
||||
pub shared_files: u32,
|
||||
pub download_dir: String,
|
||||
/// Seconds of privileges left on this account, or `null` when the server
|
||||
/// did not answer in time. Zero means an ordinary account.
|
||||
pub privilege_seconds: Option<u32>,
|
||||
}
|
||||
|
||||
impl Record for WhoamiRecord {
|
||||
@@ -405,6 +408,18 @@ impl Record for ShareStatusRecord {
|
||||
}
|
||||
}
|
||||
|
||||
/// One standing search on the wishlist.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct WishRecord {
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
impl Record for WishRecord {
|
||||
fn text(&self) -> String {
|
||||
sanitize(&self.query)
|
||||
}
|
||||
}
|
||||
|
||||
/// One configuration setting.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ConfigRecord {
|
||||
|
||||
@@ -23,6 +23,10 @@ pub struct FileConfig {
|
||||
/// Command whose stdout is the password (headless fallback, like mutt's
|
||||
/// `password_cmd`). Never store the password itself in the file.
|
||||
pub password_cmd: Option<String>,
|
||||
/// Standing searches the server lets us repeat once per wishlist interval.
|
||||
/// A TOML array rather than a `config set` key, because a query may itself
|
||||
/// contain a comma.
|
||||
pub wishlist: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FileConfig {
|
||||
@@ -131,6 +135,54 @@ impl FileConfig {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The standing searches, in the order they were added.
|
||||
#[must_use]
|
||||
pub fn wishes(&self) -> Vec<String> {
|
||||
self.wishlist.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add `query` to the wishlist. Returns whether it changed anything: a
|
||||
/// blank query and a case-insensitive repeat are both refused, so adding
|
||||
/// the same wish twice cannot fill the file with duplicates the server
|
||||
/// would then be asked about twice per interval.
|
||||
pub fn add_wish(&mut self, query: &str) -> bool {
|
||||
let query = query.trim();
|
||||
if query.is_empty() || self.find_wish(query).is_some() {
|
||||
return false;
|
||||
}
|
||||
self.wishlist
|
||||
.get_or_insert_with(Vec::new)
|
||||
.push(query.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove `query`, matched the same way [`Self::add_wish`] compares.
|
||||
/// Returns whether it was there.
|
||||
pub fn remove_wish(&mut self, query: &str) -> bool {
|
||||
let Some(index) = self.find_wish(query.trim()) else {
|
||||
return false;
|
||||
};
|
||||
self.wishlist.get_or_insert_with(Vec::new).remove(index);
|
||||
true
|
||||
}
|
||||
|
||||
/// The stored wish matching `query`, compared the way [`Self::add_wish`]
|
||||
/// compares. Callers that need to act on a named wish go through this so
|
||||
/// "the same wish" means one thing everywhere.
|
||||
#[must_use]
|
||||
pub fn wish(&self, query: &str) -> Option<&String> {
|
||||
let index = self.find_wish(query.trim())?;
|
||||
self.wishlist.as_ref()?.get(index)
|
||||
}
|
||||
|
||||
fn find_wish(&self, query: &str) -> Option<usize> {
|
||||
let wanted = query.to_lowercase();
|
||||
self.wishlist
|
||||
.as_ref()?
|
||||
.iter()
|
||||
.position(|wish| wish.to_lowercase() == wanted)
|
||||
}
|
||||
|
||||
/// Load from `path`; a missing file is an empty config, a malformed file
|
||||
/// is an error (silently ignoring a typo'd config would be worse).
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
@@ -430,6 +482,7 @@ mod tests {
|
||||
max_concurrent_downloads: Some(2),
|
||||
search_timeout: Some(30),
|
||||
password_cmd: Some("pass show slsk".into()),
|
||||
wishlist: None,
|
||||
};
|
||||
let resolved = resolve(&bare_cli(), &file);
|
||||
assert_eq!(resolved.username.as_deref(), Some("alice"));
|
||||
@@ -577,6 +630,56 @@ mod tests {
|
||||
assert_eq!(FileConfig::load(&path).unwrap(), config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wish_is_added_once_however_it_is_spelled() {
|
||||
let mut config = FileConfig::default();
|
||||
assert!(config.add_wish("Aphex Twin"));
|
||||
assert!(!config.add_wish("aphex twin"), "a repeat is not a new wish");
|
||||
assert!(!config.add_wish(" Aphex Twin "), "surrounding space too");
|
||||
assert_eq!(config.wishes(), ["Aphex Twin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_wish_is_refused() {
|
||||
let mut config = FileConfig::default();
|
||||
assert!(!config.add_wish(" "));
|
||||
assert!(config.wishes().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_wish_ignores_case_and_reports_whether_it_was_there() {
|
||||
let mut config = FileConfig::default();
|
||||
config.add_wish("Boards of Canada");
|
||||
assert!(config.remove_wish("BOARDS OF CANADA"));
|
||||
assert!(config.wishes().is_empty());
|
||||
assert!(!config.remove_wish("never added"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wishes_keep_the_order_they_were_added_in() {
|
||||
let mut config = FileConfig::default();
|
||||
for wish in ["first", "second", "third"] {
|
||||
config.add_wish(wish);
|
||||
}
|
||||
config.remove_wish("second");
|
||||
assert_eq!(config.wishes(), ["first", "third"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wishlist_survives_a_save_and_load() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let mut config = FileConfig::default();
|
||||
config.add_wish("autechre, incunabula");
|
||||
config.add_wish("plaid");
|
||||
config.save(&path).unwrap();
|
||||
assert_eq!(
|
||||
FileConfig::load(&path).unwrap().wishes(),
|
||||
["autechre, incunabula", "plaid"],
|
||||
"a comma inside a wish must survive the round trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_and_partial_files_are_tolerated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -92,6 +92,9 @@ fn build_upload_info_lines(upload: &UploadInfo) -> Vec<Line<'static>> {
|
||||
lines.push(label_value("Size", &format_bytes(upload.size)));
|
||||
|
||||
let (status_text, status_style) = match &upload.status {
|
||||
UploadStatus::Queued(place) => {
|
||||
(format!("Queued (#{place})"), inactive_style())
|
||||
}
|
||||
UploadStatus::InProgress => {
|
||||
("In progress".to_string(), warning_style())
|
||||
}
|
||||
@@ -132,7 +135,9 @@ fn build_upload_info_lines(upload: &UploadInfo) -> Vec<Line<'static>> {
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(reason.clone(), error_style())));
|
||||
}
|
||||
UploadStatus::Completed | UploadStatus::Cancelled => {}
|
||||
UploadStatus::Queued(_)
|
||||
| UploadStatus::Completed
|
||||
| UploadStatus::Cancelled => {}
|
||||
}
|
||||
|
||||
lines
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::models::DownloadEntry;
|
||||
use crate::ui::{
|
||||
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, HIGHLIGHT_SYMBOL, accent_style,
|
||||
body_style, dimmed_style, download_status_glyph, error_style, format_bytes,
|
||||
format_speed, header_style, inactive_style, info_style, pane_block,
|
||||
pane_title, primary_style, row_highlight_style, success_style,
|
||||
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
|
||||
accent_style, body_style, dimmed_style, download_status_glyph, error_style,
|
||||
format_bytes, format_speed, header_style, inactive_style, info_style,
|
||||
pane_block, pane_title, primary_style, row_highlight_style, success_style,
|
||||
warning_style,
|
||||
};
|
||||
use ratatui::{
|
||||
@@ -119,12 +119,14 @@ pub fn render_downloads_pane(
|
||||
|
||||
rows.extend(uploads.iter().map(|upload| {
|
||||
let (status_icon, status_style) = match &upload.status {
|
||||
UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()),
|
||||
UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()),
|
||||
UploadStatus::Completed => (GLYPH_DONE, success_style()),
|
||||
UploadStatus::Cancelled => (GLYPH_FAILED, inactive_style()),
|
||||
UploadStatus::Failed(_) => (GLYPH_FAILED, error_style()),
|
||||
};
|
||||
let progress_text = match &upload.status {
|
||||
UploadStatus::Queued(place) => format!("Queued #{place}"),
|
||||
UploadStatus::InProgress => {
|
||||
let percent = if upload.size > 0 {
|
||||
(upload.bytes_sent as f64 / upload.size as f64 * 100.0)
|
||||
|
||||
@@ -104,6 +104,7 @@ fn help_lists_every_scriptable_command() {
|
||||
"portmap",
|
||||
"skills",
|
||||
"completions",
|
||||
"wish",
|
||||
] {
|
||||
assert!(help.contains(command), "--help should mention {command}");
|
||||
}
|
||||
@@ -387,6 +388,85 @@ fn installing_completions_is_repeatable_and_reversible() {
|
||||
assert_eq!(action(&again), "absent");
|
||||
}
|
||||
|
||||
// --- wishlist, the half that only touches the config file ------------------
|
||||
|
||||
#[test]
|
||||
fn a_wish_is_added_listed_and_removed_through_the_config_file() {
|
||||
let dir = Scratch::new("wish");
|
||||
let config = dir.path().join("config.toml");
|
||||
|
||||
let added = with_config(&config, &["wish", "add", "aphex twin"]);
|
||||
assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added));
|
||||
assert_eq!(records(&added), ["aphex twin"]);
|
||||
|
||||
let listed = with_config(&config, &["wish", "list"]);
|
||||
assert_eq!(code(&listed), EXIT_OK);
|
||||
assert_eq!(records(&listed), ["aphex twin"]);
|
||||
|
||||
let removed = with_config(&config, &["wish", "remove", "APHEX TWIN"]);
|
||||
assert_eq!(code(&removed), EXIT_OK, "stderr: {}", stderr(&removed));
|
||||
assert!(stdout(&removed).is_empty(), "remove prints nothing");
|
||||
|
||||
let empty = with_config(&config, &["wish", "list"]);
|
||||
assert_eq!(code(&empty), EXIT_NO_RESULTS);
|
||||
assert!(stdout(&empty).is_empty(), "stdout must stay data-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_the_same_wish_twice_is_not_an_error_and_stores_it_once() {
|
||||
let dir = Scratch::new("wish");
|
||||
let config = dir.path().join("config.toml");
|
||||
|
||||
with_config(&config, &["wish", "add", "boards of canada"]);
|
||||
let again = with_config(&config, &["wish", "add", "Boards Of Canada"]);
|
||||
assert_eq!(code(&again), EXIT_OK, "a repeat is not a failure");
|
||||
|
||||
let listed = with_config(&config, &["wish", "list"]);
|
||||
assert_eq!(records(&listed), ["boards of canada"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wish_holding_a_comma_survives_the_config_file() {
|
||||
let dir = Scratch::new("wish");
|
||||
let config = dir.path().join("config.toml");
|
||||
|
||||
with_config(&config, &["wish", "add", "autechre, incunabula"]);
|
||||
let listed = with_config(&config, &["--json", "wish", "list"]);
|
||||
let stored: Vec<serde_json::Value> = records(&listed)
|
||||
.iter()
|
||||
.filter_map(|line| serde_json::from_str(line).ok())
|
||||
.collect();
|
||||
assert_eq!(stored.len(), 1, "one wish, not two split on the comma");
|
||||
assert_eq!(stored[0]["query"], "autechre, incunabula");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_wish_that_is_not_there_is_the_no_results_code() {
|
||||
let dir = Scratch::new("wish");
|
||||
let config = dir.path().join("config.toml");
|
||||
|
||||
let output = with_config(&config, &["wish", "remove", "never added"]);
|
||||
assert_eq!(code(&output), EXIT_NO_RESULTS);
|
||||
assert!(stdout(&output).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_wish_query_is_a_usage_error() {
|
||||
let dir = Scratch::new("wish");
|
||||
let config = dir.path().join("config.toml");
|
||||
|
||||
let output = with_config(&config, &["wish", "add", " "]);
|
||||
assert_eq!(code(&output), EXIT_USAGE);
|
||||
assert!(stdout(&output).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wish_add_without_a_config_file_to_write_to_is_a_usage_error() {
|
||||
let output = run(&["--no-config", "wish", "add", "nowhere to keep this"]);
|
||||
assert_eq!(code(&output), EXIT_USAGE);
|
||||
assert!(stdout(&output).is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server required (soulfind)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -625,6 +705,30 @@ fn settle() {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// Like [`cli`], but keeping a config file so a command that reads or writes
|
||||
/// one (the wishlist) sees it.
|
||||
///
|
||||
/// `--no-config` is dropped for the obvious reason, and `--quiet` with it:
|
||||
/// these commands report the schedule the *server* chose on stderr, which is
|
||||
/// the only place a test can see it.
|
||||
fn cli_with_config(
|
||||
server: &TestServer,
|
||||
user: &str,
|
||||
config: &Path,
|
||||
args: &[&str],
|
||||
) -> Output {
|
||||
let mut all: Vec<String> = server
|
||||
.args(user)
|
||||
.into_iter()
|
||||
.filter(|arg| arg != "--no-config" && arg != "--quiet")
|
||||
.collect();
|
||||
all.push("--config".to_string());
|
||||
all.push(config.to_str().expect("utf-8 path").to_string());
|
||||
all.extend(args.iter().map(|a| (*a).to_string()));
|
||||
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
|
||||
run(&refs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_password_is_a_connection_error() {
|
||||
let server = server_or_skip!();
|
||||
@@ -726,6 +830,470 @@ fn a_search_with_no_matches_exits_with_the_no_results_code() {
|
||||
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
|
||||
}
|
||||
|
||||
/// One share holding three files that differ in name, extension and size, so
|
||||
/// every result filter has something to keep and something to drop.
|
||||
///
|
||||
/// `flt_beta_skipme.flac` is deliberately the small one and
|
||||
/// `flt_gamma_keepme.ogg` the large one, so a size bound and an extension
|
||||
/// bound select different files and a combination of the two is a real test
|
||||
/// rather than two spellings of the same assertion.
|
||||
struct FilterFixture {
|
||||
_share: Scratch,
|
||||
}
|
||||
|
||||
impl FilterFixture {
|
||||
const SMALL: usize = 128;
|
||||
const MEDIUM: usize = 4096;
|
||||
const LARGE: usize = 8192;
|
||||
|
||||
fn new(server: &TestServer, sharer: &str) -> (Self, Client) {
|
||||
let share = Scratch::new("filter-share");
|
||||
for (name, size) in [
|
||||
("flt_alpha_keepme.mp3", Self::MEDIUM),
|
||||
("flt_beta_skipme.flac", Self::SMALL),
|
||||
("flt_gamma_keepme.ogg", Self::LARGE),
|
||||
] {
|
||||
std::fs::write(share.path().join(name), vec![7u8; size])
|
||||
.expect("share file");
|
||||
}
|
||||
let client = server.client(sharer, vec![share.display()]);
|
||||
settle();
|
||||
(Self { _share: share }, client)
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for the fixture as a fresh user and return the matching file names,
|
||||
/// sorted so the assertion does not depend on result order.
|
||||
///
|
||||
/// A new username per call matters: soulfind answers a repeat of the same
|
||||
/// search from the same user with nothing.
|
||||
fn filtered_names(
|
||||
server: &TestServer,
|
||||
seeker: &str,
|
||||
filter: &[&str],
|
||||
) -> (i32, Vec<String>) {
|
||||
let mut args = vec!["--search-timeout", "5", "search", "flt"];
|
||||
args.extend_from_slice(filter);
|
||||
let output = cli(server, seeker, &args);
|
||||
let mut names: Vec<String> = records(&output)
|
||||
.iter()
|
||||
.filter_map(|line| line.split('\t').nth(3))
|
||||
.filter_map(|path| path.rsplit(['/', '\\']).next())
|
||||
.filter(|name| name.starts_with("flt_"))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
names.sort();
|
||||
(code(&output), names)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_drops_the_results_whose_path_contains_the_term() {
|
||||
let server = server_or_skip!();
|
||||
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_excl");
|
||||
|
||||
let (exit, names) =
|
||||
filtered_names(&server, "cli_e2e_seek_excl", &["--exclude", "skipme"]);
|
||||
assert_eq!(exit, EXIT_OK);
|
||||
assert_eq!(names, ["flt_alpha_keepme.mp3", "flt_gamma_keepme.ogg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_keeps_one_type_and_repeats_into_a_union() {
|
||||
let server = server_or_skip!();
|
||||
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_ext");
|
||||
|
||||
let (exit, one) = filtered_names(
|
||||
&server,
|
||||
"cli_e2e_seek_ext_one",
|
||||
&["--extension", "mp3"],
|
||||
);
|
||||
assert_eq!(exit, EXIT_OK);
|
||||
assert_eq!(one, ["flt_alpha_keepme.mp3"]);
|
||||
|
||||
let (exit, several) = filtered_names(
|
||||
&server,
|
||||
"cli_e2e_seek_ext_many",
|
||||
&["--extension", ".flac", "--extension", "OGG"],
|
||||
);
|
||||
assert_eq!(exit, EXIT_OK);
|
||||
assert_eq!(several, ["flt_beta_skipme.flac", "flt_gamma_keepme.ogg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_bounds_narrow_the_result_set_from_either_end() {
|
||||
let server = server_or_skip!();
|
||||
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_size");
|
||||
|
||||
let (exit, big) = filtered_names(
|
||||
&server,
|
||||
"cli_e2e_seek_size_min",
|
||||
&["--min-size", &FilterFixture::MEDIUM.to_string()],
|
||||
);
|
||||
assert_eq!(exit, EXIT_OK);
|
||||
assert_eq!(big, ["flt_alpha_keepme.mp3", "flt_gamma_keepme.ogg"]);
|
||||
|
||||
let (exit, small) = filtered_names(
|
||||
&server,
|
||||
"cli_e2e_seek_size_max",
|
||||
&["--max-size", &FilterFixture::SMALL.to_string()],
|
||||
);
|
||||
assert_eq!(exit, EXIT_OK);
|
||||
assert_eq!(small, ["flt_beta_skipme.flac"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_given_together_all_have_to_be_satisfied() {
|
||||
let server = server_or_skip!();
|
||||
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_comb");
|
||||
|
||||
let (exit, names) = filtered_names(
|
||||
&server,
|
||||
"cli_e2e_seek_comb",
|
||||
&[
|
||||
"--exclude",
|
||||
"skipme",
|
||||
"--min-size",
|
||||
&FilterFixture::LARGE.to_string(),
|
||||
"--extension",
|
||||
"ogg",
|
||||
],
|
||||
);
|
||||
assert_eq!(exit, EXIT_OK);
|
||||
assert_eq!(names, ["flt_gamma_keepme.ogg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_that_matches_nothing_is_the_no_results_code() {
|
||||
let server = server_or_skip!();
|
||||
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_none");
|
||||
|
||||
let output = cli(
|
||||
&server,
|
||||
"cli_e2e_seek_none",
|
||||
&[
|
||||
"--search-timeout",
|
||||
"5",
|
||||
"search",
|
||||
"flt",
|
||||
"--extension",
|
||||
"aiff",
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
code(&output),
|
||||
EXIT_NO_RESULTS,
|
||||
"stderr: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_honours_the_same_filters_search_does() {
|
||||
let server = server_or_skip!();
|
||||
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_get");
|
||||
let target = Scratch::new("filter-downloads");
|
||||
|
||||
let output = cli(
|
||||
&server,
|
||||
"cli_e2e_seek_get",
|
||||
&[
|
||||
"--search-timeout",
|
||||
"5",
|
||||
"--download-dir",
|
||||
&target.display(),
|
||||
"get",
|
||||
"flt",
|
||||
"--extension",
|
||||
"ogg",
|
||||
"--timeout",
|
||||
"30",
|
||||
],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
assert!(
|
||||
target.path().join("flt_gamma_keepme.ogg").exists(),
|
||||
"the only .ogg should be what got fetched; stdout: {}",
|
||||
stdout(&output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wish_run_finds_a_shared_file_through_a_wishlist_search() {
|
||||
let server = server_or_skip!();
|
||||
let share = Scratch::new("wish-share");
|
||||
std::fs::write(share.path().join("wisher_probe_krel.mp3"), probe_bytes())
|
||||
.expect("share file");
|
||||
let _sharer = server.client("cli_e2e_wish_sharer", vec![share.display()]);
|
||||
let dir = Scratch::new("wish-config");
|
||||
let config = dir.path().join("config.toml");
|
||||
settle();
|
||||
|
||||
let added = with_config(&config, &["wish", "add", "krel"]);
|
||||
assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added));
|
||||
|
||||
let output = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_wish_seeker",
|
||||
&config,
|
||||
&["--search-timeout", "5", "wish", "run"],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
|
||||
let record = records(&output)
|
||||
.into_iter()
|
||||
.find(|line| line.contains("wisher_probe_krel"))
|
||||
.expect("the wishlist search should find the sharer's file");
|
||||
// The same four columns `search` prints, so this pipes into
|
||||
// `download --stdin` with no reshaping.
|
||||
let fields: Vec<&str> = record.split('\t').collect();
|
||||
assert_eq!(fields.len(), 4, "record was {record:?}");
|
||||
assert_eq!(fields[0], "cli_e2e_wish_sharer");
|
||||
assert_eq!(fields[1], probe_bytes().len().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wish_run_output_feeds_download_stdin() {
|
||||
let server = server_or_skip!();
|
||||
let share = Scratch::new("wish-share");
|
||||
std::fs::write(share.path().join("wisher_probe_vosk.mp3"), probe_bytes())
|
||||
.expect("share file");
|
||||
let _sharer = server.client("cli_e2e_wish_sharer_b", vec![share.display()]);
|
||||
let dir = Scratch::new("wish-config");
|
||||
let config = dir.path().join("config.toml");
|
||||
let target = Scratch::new("wish-downloads");
|
||||
settle();
|
||||
|
||||
with_config(&config, &["wish", "add", "vosk"]);
|
||||
let found = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_wish_seeker_b",
|
||||
&config,
|
||||
&["--search-timeout", "5", "--json", "wish", "run"],
|
||||
);
|
||||
assert_eq!(code(&found), EXIT_OK, "stderr: {}", stderr(&found));
|
||||
|
||||
let mut child = command(&[
|
||||
"--no-config",
|
||||
"--quiet",
|
||||
"--server",
|
||||
&server.address(),
|
||||
"--username",
|
||||
"cli_e2e_wish_puller",
|
||||
"--password",
|
||||
"pw",
|
||||
"--listener-port",
|
||||
&free_port().expect("listener port").to_string(),
|
||||
"--download-dir",
|
||||
&target.display(),
|
||||
"download",
|
||||
"--stdin",
|
||||
"--timeout",
|
||||
"30",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("download should start");
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("stdin")
|
||||
.write_all(stdout(&found).as_bytes())
|
||||
.expect("feed the records back");
|
||||
let pulled = child.wait_with_output().expect("download should finish");
|
||||
|
||||
assert_eq!(code(&pulled), EXIT_OK, "stderr: {}", stderr(&pulled));
|
||||
assert!(
|
||||
target.path().join("wisher_probe_vosk.mp3").exists(),
|
||||
"the wished-for file should have landed; stdout: {}",
|
||||
stdout(&pulled)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wish_run_with_an_empty_wishlist_is_the_no_results_code() {
|
||||
let server = server_or_skip!();
|
||||
let _peer = server.client("cli_e2e_wish_quiet", Vec::new());
|
||||
let dir = Scratch::new("wish-config");
|
||||
let config = dir.path().join("config.toml");
|
||||
|
||||
let output = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_wish_none",
|
||||
&config,
|
||||
&["wish", "run"],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_NO_RESULTS);
|
||||
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wish_run_only_must_name_a_stored_wish() {
|
||||
let server = server_or_skip!();
|
||||
let _peer = server.client("cli_e2e_wish_quiet_b", Vec::new());
|
||||
let dir = Scratch::new("wish-config");
|
||||
let config = dir.path().join("config.toml");
|
||||
with_config(&config, &["wish", "add", "stored one"]);
|
||||
|
||||
let unknown = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_wish_only_bad",
|
||||
&config,
|
||||
&["wish", "run", "--only", "not stored"],
|
||||
);
|
||||
assert_eq!(code(&unknown), EXIT_USAGE, "a typo is not an ad-hoc search");
|
||||
assert!(stdout(&unknown).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wish_run_only_narrows_the_sweep_to_that_wish() {
|
||||
let server = server_or_skip!();
|
||||
let share = Scratch::new("wish-share");
|
||||
for name in ["wisher_probe_zant.mp3", "wisher_probe_hurl.mp3"] {
|
||||
std::fs::write(share.path().join(name), probe_bytes())
|
||||
.expect("share file");
|
||||
}
|
||||
let _sharer = server.client("cli_e2e_wish_sharer_c", vec![share.display()]);
|
||||
let dir = Scratch::new("wish-config");
|
||||
let config = dir.path().join("config.toml");
|
||||
settle();
|
||||
|
||||
with_config(&config, &["wish", "add", "zant"]);
|
||||
with_config(&config, &["wish", "add", "hurl"]);
|
||||
|
||||
let output = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_wish_only_ok",
|
||||
&config,
|
||||
&["--search-timeout", "5", "wish", "run", "--only", "ZANT"],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
|
||||
let paths = stdout(&output);
|
||||
assert!(paths.contains("wisher_probe_zant"), "stdout was {paths:?}");
|
||||
assert!(
|
||||
!paths.contains("wisher_probe_hurl"),
|
||||
"--only must not run the other wish; stdout was {paths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wish_run_filters_apply_to_a_wishlist_search_too() {
|
||||
let server = server_or_skip!();
|
||||
let share = Scratch::new("wish-share");
|
||||
std::fs::write(share.path().join("wisher_probe_gleb.mp3"), probe_bytes())
|
||||
.expect("share file");
|
||||
std::fs::write(share.path().join("wisher_probe_gleb.flac"), probe_bytes())
|
||||
.expect("share file");
|
||||
let _sharer = server.client("cli_e2e_wish_sharer_d", vec![share.display()]);
|
||||
let dir = Scratch::new("wish-config");
|
||||
let config = dir.path().join("config.toml");
|
||||
settle();
|
||||
|
||||
with_config(&config, &["wish", "add", "gleb"]);
|
||||
let output = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_wish_filtered",
|
||||
&config,
|
||||
&[
|
||||
"--search-timeout",
|
||||
"5",
|
||||
"wish",
|
||||
"run",
|
||||
"--extension",
|
||||
"flac",
|
||||
],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
let paths = stdout(&output);
|
||||
assert!(paths.contains("wisher_probe_gleb.flac"), "{paths:?}");
|
||||
assert!(!paths.contains("wisher_probe_gleb.mp3"), "{paths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_with_the_wishlist_sweeps_on_the_interval_the_server_announced() {
|
||||
let server = server_or_skip!();
|
||||
let their_share = Scratch::new("wish-their-share");
|
||||
std::fs::write(
|
||||
their_share.path().join("wisher_probe_yalt.mp3"),
|
||||
probe_bytes(),
|
||||
)
|
||||
.expect("share file");
|
||||
let _sharer =
|
||||
server.client("cli_e2e_wish_sharer_e", vec![their_share.display()]);
|
||||
|
||||
// A serving client needs something of its own to share.
|
||||
let our_share = Scratch::new("wish-our-share");
|
||||
std::fs::write(our_share.path().join("ours.bin"), probe_bytes())
|
||||
.expect("share file");
|
||||
let dir = Scratch::new("wish-config");
|
||||
let config = dir.path().join("config.toml");
|
||||
settle();
|
||||
|
||||
with_config(&config, &["wish", "add", "yalt"]);
|
||||
let output = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_wish_server",
|
||||
&config,
|
||||
&[
|
||||
"--search-timeout",
|
||||
"5",
|
||||
"--shared-dir",
|
||||
&our_share.display(),
|
||||
"serve",
|
||||
"--wishlist",
|
||||
"--duration",
|
||||
"12",
|
||||
],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
assert!(
|
||||
stdout(&output).contains("wisher_probe_yalt"),
|
||||
"the first sweep happens at startup; stdout: {}",
|
||||
stdout(&output)
|
||||
);
|
||||
// Soulfind announces 12 minutes for an unprivileged account, and the
|
||||
// schedule is the server's to set — so that is what must be reported.
|
||||
assert!(
|
||||
stderr(&output).contains("next wishlist sweep in 720s"),
|
||||
"the announced interval should drive the timer; stderr: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_wishlist_with_nothing_wished_for_still_serves() {
|
||||
let server = server_or_skip!();
|
||||
let our_share = Scratch::new("wish-our-share");
|
||||
std::fs::write(our_share.path().join("ours.bin"), probe_bytes())
|
||||
.expect("share file");
|
||||
let dir = Scratch::new("wish-config");
|
||||
let config = dir.path().join("config.toml");
|
||||
|
||||
let output = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_wish_server_b",
|
||||
&config,
|
||||
&[
|
||||
"--shared-dir",
|
||||
&our_share.display(),
|
||||
"serve",
|
||||
"--wishlist",
|
||||
"--duration",
|
||||
"2",
|
||||
],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
assert!(
|
||||
stderr(&output).contains("nothing to look for"),
|
||||
"an empty wishlist should be said once, not serve-and-be-silent; \
|
||||
stderr: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_lists_shares_as_paths_download_understands() {
|
||||
let server = server_or_skip!();
|
||||
@@ -1155,6 +1723,122 @@ fn whoami_confirms_the_login_and_reports_what_we_offer() {
|
||||
assert!(record["server"].as_str().is_some_and(|s| s.contains(':')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whoami_reports_how_much_privilege_time_the_account_has() {
|
||||
let server = server_or_skip!();
|
||||
let _owner = server.client("cli_e2e_priv_owner", Vec::new());
|
||||
|
||||
let output = cli(&server, "cli_e2e_priv_owner", &["--json", "whoami"]);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
|
||||
let record: serde_json::Value =
|
||||
serde_json::from_str(&records(&output)[0]).expect("valid JSON");
|
||||
// Zero, not null: the server answered, and the answer is "no privileges".
|
||||
// Null would mean it never said, which is a different fact.
|
||||
assert_eq!(
|
||||
record["privilege_seconds"], 0,
|
||||
"a plain account should read as zero seconds, got {record}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_reports_a_queued_upload_once_the_slots_are_full() {
|
||||
let server = server_or_skip!();
|
||||
let share = Scratch::new("queue-share");
|
||||
for name in ["q_one.bin", "q_two.bin"] {
|
||||
std::fs::write(share.path().join(name), probe_bytes())
|
||||
.expect("share file");
|
||||
}
|
||||
let target_a = Scratch::new("queue-dl-a");
|
||||
let target_b = Scratch::new("queue-dl-b");
|
||||
|
||||
let serving = command(&[
|
||||
"--no-config",
|
||||
"--server",
|
||||
&server.address(),
|
||||
"--username",
|
||||
"cli_e2e_queue_server",
|
||||
"--password",
|
||||
"pw",
|
||||
"--listener-port",
|
||||
&free_port().expect("listener port").to_string(),
|
||||
"--shared-dir",
|
||||
&share.display(),
|
||||
"--json",
|
||||
"serve",
|
||||
"--upload-slots",
|
||||
"1",
|
||||
"--duration",
|
||||
"18",
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("serve should start");
|
||||
settle();
|
||||
settle();
|
||||
|
||||
// Two peers pull at once against a single slot, so one of them has to wait.
|
||||
let mut pulls = Vec::new();
|
||||
for (user, target) in [
|
||||
("cli_e2e_queue_a", &target_a),
|
||||
("cli_e2e_queue_b", &target_b),
|
||||
] {
|
||||
pulls.push(
|
||||
command(&[
|
||||
"--no-config",
|
||||
"--quiet",
|
||||
"--server",
|
||||
&server.address(),
|
||||
"--username",
|
||||
user,
|
||||
"--password",
|
||||
"pw",
|
||||
"--listener-port",
|
||||
&free_port().expect("listener port").to_string(),
|
||||
"--download-dir",
|
||||
&target.display(),
|
||||
"--search-timeout",
|
||||
"5",
|
||||
"get",
|
||||
"q_",
|
||||
"--pick",
|
||||
"all",
|
||||
"--timeout",
|
||||
"30",
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("puller should start"),
|
||||
);
|
||||
}
|
||||
for mut pull in pulls {
|
||||
let _ = pull.wait();
|
||||
}
|
||||
|
||||
let uploaded = serving.wait_with_output().expect("serve should finish");
|
||||
let statuses: Vec<String> = stdout(&uploaded)
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||
.filter_map(|value| {
|
||||
value["status"]
|
||||
.as_str()
|
||||
.map(std::string::ToString::to_string)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
statuses.iter().any(|status| status == "completed"),
|
||||
"at least one upload should have gone through; stdout: {}",
|
||||
stdout(&uploaded)
|
||||
);
|
||||
assert!(
|
||||
statuses.iter().any(|status| status == "queued"),
|
||||
"with one slot and two peers pulling, `serve` should report a queued \
|
||||
upload; statuses were {statuses:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whoami_fails_with_the_connection_code_on_a_bad_password() {
|
||||
let server = server_or_skip!();
|
||||
@@ -1658,6 +2342,11 @@ fn the_commands_advertised_as_credential_free_run_without_an_account() {
|
||||
vec!["shares", "add", &shared_path],
|
||||
vec!["shares", "list"],
|
||||
vec!["shares", "remove", &shared_path],
|
||||
// The wishlist is stored, not searched, until `wish run` asks the
|
||||
// network — so managing it must not demand an account either.
|
||||
vec!["wish", "add", "something rare"],
|
||||
vec!["wish", "list"],
|
||||
vec!["wish", "remove", "something rare"],
|
||||
vec!["skills", "install", "--dir", &skills],
|
||||
vec!["skills", "list", "--dir", &skills],
|
||||
vec!["skills", "uninstall", "--dir", &skills],
|
||||
|
||||
Reference in New Issue
Block a user