feat: conventional default download and share folders

The default download directory is now the platform Downloads folder plus
a Soulseek subfolder (via the directories crate — XDG on Linux, known
folder on Windows) instead of a tilde path that only worked with a HOME
variable. Sharing follows the Soulseek convention of sharing what you
download: shared_dir defaults to the download folder, and an explicitly
empty shared_dir opts out. The folder is created at startup so first-run
share validation succeeds.
This commit is contained in:
michel
2026-07-20 20:43:27 +02:00
parent 8221045aa0
commit 8389d525e7
4 changed files with 86 additions and 8 deletions
+2 -1
View File
@@ -40,7 +40,8 @@ pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
/// Directory downloads are saved to (default: ~/Downloads)
/// Directory downloads are saved to (default: your Downloads/Soulseek
/// folder, which is also shared unless shared_dir says otherwise)
#[arg(short, long, env = "SOULSEEK_DOWNLOAD_DIR")]
pub download_dir: Option<String>,
+10
View File
@@ -172,6 +172,16 @@ fn run_default_tui(
let (server_host, server_port) = parse_server_address(&resolved.server)?;
// Make sure the download folder exists up front: first-run defaults point
// download and shared at the same fresh Downloads/Soulseek folder, and
// shared-directory validation requires an existing path.
if let Err(e) = std::fs::create_dir_all(&resolved.download_dir) {
eprintln!(
"⚠️ Could not create download directory {}: {e}",
resolved.download_dir
);
}
let shared_directory = match directories::resolve_shared_directory(
resolved.shared_dir.as_deref(),
) {
+56 -7
View File
@@ -69,7 +69,6 @@ pub struct Resolved {
pub const DEFAULT_SERVER: &str = "server.slsknet.org:2416";
pub const DEFAULT_LISTENER_PORT: u16 = 2234;
pub const DEFAULT_DOWNLOAD_DIR: &str = "~/Downloads";
pub const DEFAULT_MAX_CONCURRENT_DOWNLOADS: usize = 5;
pub const DEFAULT_SEARCH_TIMEOUT: u64 = 10;
@@ -80,6 +79,11 @@ pub const DEFAULT_SEARCH_TIMEOUT: u64 = 10;
/// unless the flag is passed.
#[must_use]
pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
let download_dir = cli
.download_dir
.clone()
.or_else(|| file.download_dir.clone())
.unwrap_or_else(super::paths::default_download_dir);
Resolved {
username: cli.username.clone().or_else(|| file.username.clone()),
server: cli
@@ -93,12 +97,15 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
.unwrap_or(DEFAULT_LISTENER_PORT),
disable_listener: cli.disable_listener
|| file.disable_listener.unwrap_or(false),
download_dir: cli
.download_dir
download_dir: download_dir.clone(),
// Soulseek convention: share what you download. An explicitly empty
// shared_dir ("") opts out of sharing entirely.
shared_dir: cli
.shared_dir
.clone()
.or_else(|| file.download_dir.clone())
.unwrap_or_else(|| DEFAULT_DOWNLOAD_DIR.to_string()),
shared_dir: cli.shared_dir.clone().or_else(|| file.shared_dir.clone()),
.or_else(|| file.shared_dir.clone())
.or(Some(download_dir))
.filter(|dir| !dir.trim().is_empty()),
max_concurrent_downloads: cli
.max_concurrent_downloads
.or(file.max_concurrent_downloads)
@@ -138,7 +145,6 @@ mod tests {
let resolved = resolve(&bare_cli(), &FileConfig::default());
assert_eq!(resolved.server, DEFAULT_SERVER);
assert_eq!(resolved.listener_port, DEFAULT_LISTENER_PORT);
assert_eq!(resolved.download_dir, DEFAULT_DOWNLOAD_DIR);
assert_eq!(
resolved.max_concurrent_downloads,
DEFAULT_MAX_CONCURRENT_DOWNLOADS
@@ -148,6 +154,49 @@ mod tests {
assert_eq!(resolved.username, None);
}
#[test]
fn default_download_dir_is_a_soulseek_folder_under_downloads() {
let resolved = resolve(&bare_cli(), &FileConfig::default());
let path = std::path::Path::new(&resolved.download_dir);
assert!(path.is_absolute(), "must not rely on ~ expansion");
assert_eq!(path.file_name().and_then(|n| n.to_str()), Some("Soulseek"));
assert_eq!(
path.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str()),
Some("Downloads")
);
}
#[test]
fn shared_dir_defaults_to_the_download_dir() {
let resolved = resolve(&bare_cli(), &FileConfig::default());
assert_eq!(
resolved.shared_dir.as_deref(),
Some(resolved.download_dir.as_str())
);
}
#[test]
fn shared_dir_follows_a_customized_download_dir() {
let file = FileConfig {
download_dir: Some("/music".into()),
..FileConfig::default()
};
let resolved = resolve(&bare_cli(), &file);
assert_eq!(resolved.shared_dir.as_deref(), Some("/music"));
}
#[test]
fn empty_shared_dir_disables_sharing() {
let file = FileConfig {
shared_dir: Some(String::new()),
..FileConfig::default()
};
let resolved = resolve(&bare_cli(), &file);
assert_eq!(resolved.shared_dir, None);
}
#[test]
fn file_values_override_defaults() {
let file = FileConfig {
+18
View File
@@ -23,3 +23,21 @@ pub fn state_dir() -> Option<PathBuf> {
fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "soulseek-rs")
}
/// Default download (and shared) folder: a `Soulseek` subfolder of the
/// platform Downloads directory (XDG on Linux, known-folder on Windows),
/// so Soulseek files don't clutter the Downloads root.
#[must_use]
pub fn default_download_dir() -> String {
let user_dirs = directories::UserDirs::new();
let downloads = user_dirs
.as_ref()
.and_then(|dirs| dirs.download_dir().map(std::path::Path::to_path_buf))
.or_else(|| {
user_dirs
.as_ref()
.map(|dirs| dirs.home_dir().join("Downloads"))
})
.unwrap_or_else(|| std::path::PathBuf::from("Downloads"));
downloads.join("Soulseek").display().to_string()
}