From 1328736550d10bd226ad23689ebdb4a8515a58a0 Mon Sep 17 00:00:00 2001 From: michel Date: Sun, 19 Jul 2026 13:12:44 +0200 Subject: [PATCH] feat: configure download & upload (shared) directories Add a --shared-dir / SOULSEEK_SHARED_DIR option and a SOULSEEK_DOWNLOAD_DIR env for the download directory. The shared directory is resolved and validated up front (tilde-expanded, must exist and be a directory); a misconfigured value is a warning, not a fatal error. The library gains an optional shared_directory on ClientSettings/Client with a getter, keeping soulseek-rs-lib dependency-free. Resolution lives in a new soulseek-rs/src/directories.rs with unit tests. --- soulseek-rs-lib/src/client.rs | 12 +++++ soulseek-rs-lib/tests/e2e.rs | 1 + soulseek-rs/src/cli.rs | 14 +++++- soulseek-rs/src/config.rs | 1 + soulseek-rs/src/directories.rs | 85 ++++++++++++++++++++++++++++++++++ soulseek-rs/src/main.rs | 15 ++++++ 6 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 soulseek-rs/src/directories.rs diff --git a/soulseek-rs-lib/src/client.rs b/soulseek-rs-lib/src/client.rs index fe1aaff..30a5a1a 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client.rs @@ -36,6 +36,9 @@ pub struct ClientSettings { pub server_address: PeerAddress, pub enable_listen: bool, pub listen_port: u16, + /// Directory whose files are shared with (uploaded to) other peers. + /// `None` means nothing is shared. + pub shared_directory: Option, } impl ClientSettings { @@ -62,6 +65,7 @@ impl Default for ClientSettings { ), enable_listen: true, listen_port: DEFAULT_LISTEN_PORT, + shared_directory: None, } } } @@ -314,6 +318,7 @@ pub struct Client { address: PeerAddress, username: String, password: String, + shared_directory: Option, server_handle: Option>, context: Arc>, } @@ -335,11 +340,18 @@ impl Client { address: settings.server_address, username: settings.username, password: settings.password, + shared_directory: settings.shared_directory, context: Arc::new(RwLock::new(ClientContext::new())), server_handle: None, } } + /// The directory whose files are shared with other peers, if configured. + #[must_use] + pub fn shared_directory(&self) -> Option<&str> { + self.shared_directory.as_deref() + } + pub fn connect(&mut self) -> Result<()> { let (sender, message_reader): ( Sender, diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 1f51c2c..6921e89 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -91,6 +91,7 @@ impl TestServer { server_address: PeerAddress::new(self.host.clone(), self.port), enable_listen: false, listen_port: 0, + shared_directory: None, } } } diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 6b0f7e2..6cf4336 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -43,9 +43,21 @@ pub struct Cli { #[command(subcommand)] pub command: Option, - #[arg(short, long, default_value = "~/Downloads")] + #[arg( + short, + long, + env = "SOULSEEK_DOWNLOAD_DIR", + default_value = "~/Downloads" + )] pub download_dir: String, + #[arg( + long, + env = "SOULSEEK_SHARED_DIR", + help = "Directory whose files are shared with (uploaded to) other peers" + )] + pub shared_dir: Option, + #[arg( short = 'c', long, diff --git a/soulseek-rs/src/config.rs b/soulseek-rs/src/config.rs index d4c3e3c..6bc0c3a 100644 --- a/soulseek-rs/src/config.rs +++ b/soulseek-rs/src/config.rs @@ -10,4 +10,5 @@ pub struct SearchConfig { pub download_dir: String, pub verbose: u8, pub max_concurrent_downloads: usize, + pub shared_directory: Option, } diff --git a/soulseek-rs/src/directories.rs b/soulseek-rs/src/directories.rs new file mode 100644 index 0000000..b90dd11 --- /dev/null +++ b/soulseek-rs/src/directories.rs @@ -0,0 +1,85 @@ +//! Resolution and validation of the configured download / shared directories. + +use soulseek_rs::utils::path::expand_tilde; +use std::path::PathBuf; + +/// Validate and normalize an optional shared-directory setting. +/// +/// Returns `Ok(None)` when nothing is shared (unset or blank), `Ok(Some(path))` +/// for a `~`-expanded, existing directory, or `Err` with a human-readable +/// message when the configured path is not a usable directory. +pub fn resolve_shared_directory( + raw: Option<&str>, +) -> Result, String> { + let Some(raw) = raw else { + return Ok(None); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let path = expand_tilde(trimmed); + if !path.exists() { + return Err(format!( + "shared directory does not exist: {}", + path.display() + )); + } + if !path.is_dir() { + return Err(format!( + "shared path is not a directory: {}", + path.display() + )); + } + Ok(Some(path)) +} + +#[cfg(test)] +mod tests { + use super::resolve_shared_directory; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn unique_temp_dir() -> std::path::PathBuf { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir() + .join(format!("soulseek-rs-dirtest-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn unset_or_blank_shares_nothing() { + assert_eq!(resolve_shared_directory(None), Ok(None)); + assert_eq!(resolve_shared_directory(Some("")), Ok(None)); + assert_eq!(resolve_shared_directory(Some(" ")), Ok(None)); + } + + #[test] + fn existing_directory_is_accepted() { + let dir = unique_temp_dir(); + let resolved = + resolve_shared_directory(Some(dir.to_str().unwrap())).unwrap(); + assert_eq!(resolved, Some(dir.clone())); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_directory_is_rejected() { + let missing = std::env::temp_dir().join("soulseek-rs-does-not-exist-x"); + let _ = std::fs::remove_dir_all(&missing); + let result = resolve_shared_directory(Some(missing.to_str().unwrap())); + assert!(result.is_err()); + } + + #[test] + fn a_file_is_not_a_valid_shared_directory() { + let dir = unique_temp_dir(); + let file = dir.join("not-a-dir.txt"); + std::fs::write(&file, b"x").unwrap(); + let result = resolve_shared_directory(Some(file.to_str().unwrap())); + assert!(result.is_err()); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 6e4abf3..a0b2fd4 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod config; +mod directories; mod models; mod ui; @@ -53,6 +54,17 @@ fn main() -> Result<()> { let (server_host, server_port) = parse_server_address(&cli.server)?; + // Resolve the optional shared/upload directory up front; a misconfigured + // one is a warning, not a fatal error (the client just shares nothing). + let shared_directory = + match directories::resolve_shared_directory(cli.shared_dir.as_deref()) { + Ok(dir) => dir.map(|path| path.display().to_string()), + Err(e) => { + eprintln!("⚠️ Ignoring shared directory: {e}"); + None + } + }; + match cli.command { Some(Commands::Search { query, @@ -72,6 +84,7 @@ fn main() -> Result<()> { download_dir, verbose: cli.verbose, max_concurrent_downloads, + shared_directory, }; search_and_download(config) } @@ -92,6 +105,7 @@ fn main() -> Result<()> { server_address: PeerAddress::new(server_host, server_port), enable_listen: !cli.disable_listener, listen_port: cli.listener_port, + shared_directory, }; let mut client = Client::with_settings(settings); @@ -141,6 +155,7 @@ fn search_and_download(config: SearchConfig) -> Result<()> { ), enable_listen: config.enable_listener, listen_port: config.listener_port, + shared_directory: config.shared_directory.clone(), }; let mut client = Client::with_settings(settings);