diff --git a/Cargo.lock b/Cargo.lock index eeaee93..fbdde3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,6 +483,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -566,6 +587,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -644,6 +671,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -846,6 +884,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.7" @@ -935,6 +983,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -1123,6 +1180,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -1484,6 +1547,17 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.13.1" @@ -1595,6 +1669,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1670,10 +1766,15 @@ version = "5.0.0" dependencies = [ "clap", "color-eyre", + "directories", "dotenv", "igd-next", "ratatui", + "serde", + "serde_json", "soulseek-rs-lib", + "tempfile", + "toml", ] [[package]] @@ -1752,6 +1853,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termina" version = "0.3.3" @@ -1918,6 +2032,45 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" @@ -2226,6 +2379,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -2329,3 +2494,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index af03fc4..671fb81 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -24,3 +24,10 @@ ratatui = "0.30.2" color-eyre = "0.6.5" dotenv = "0.15.0" igd-next = "0.17" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.9" +directories = "6" + +[dev-dependencies] +tempfile = "3" diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 523ed59..9ce2ec5 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -16,19 +16,16 @@ pub struct Cli { #[arg(short, long, env = "SOULSEEK_PASSWORD")] pub password: Option, - #[arg( - short, - long, - env = "SOULSEEK_SERVER", - default_value = "server.slsknet.org:2416" - )] - pub server: String, + /// Server address as host:port (default: server.slsknet.org:2416) + #[arg(short, long, env = "SOULSEEK_SERVER")] + pub server: Option, #[arg(long, env = "DISABLE_LISTENER")] pub disable_listener: bool, - #[arg(short, long, env = "LISTENER_PORT", default_value = "2234")] - pub listener_port: u16, + /// Port to accept peer connections on (default: 2234) + #[arg(short, long, env = "LISTENER_PORT")] + pub listener_port: Option, #[arg(short, long, global = true, action = clap::ArgAction::Count)] pub verbose: u8, @@ -43,13 +40,9 @@ pub struct Cli { #[command(subcommand)] pub command: Option, - #[arg( - short, - long, - env = "SOULSEEK_DOWNLOAD_DIR", - default_value = "~/Downloads" - )] - pub download_dir: String, + /// Directory downloads are saved to (default: ~/Downloads) + #[arg(short, long, env = "SOULSEEK_DOWNLOAD_DIR")] + pub download_dir: Option, #[arg( long, @@ -58,16 +51,13 @@ pub struct Cli { )] pub shared_dir: Option, - #[arg( - short = 'c', - long, - env = "MAX_CONCURRENT_DOWNLOADS", - default_value = "5" - )] - pub max_concurrent_downloads: usize, + /// Maximum simultaneous downloads (default: 5) + #[arg(short = 'c', long, env = "MAX_CONCURRENT_DOWNLOADS")] + pub max_concurrent_downloads: Option, - #[arg(long, default_value = "10")] - pub search_timeout: u64, + /// Seconds a search stays active (default: 10) + #[arg(long)] + pub search_timeout: Option, } #[derive(Subcommand, Debug)] diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index b2b2448..a8ec30c 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod config; mod directories; mod models; +mod persist; mod port_mapping; mod ui; @@ -25,35 +26,43 @@ fn main() -> Result<()> { init_logging(&cli); + // Layer CLI/env values over config.toml over built-in defaults. + let config_path = persist::paths::config_file(); + let file_config = match &config_path { + Some(path) => persist::config::FileConfig::load(path)?, + None => persist::config::FileConfig::default(), + }; + let resolved = persist::config::resolve(&cli, &file_config); + // `portmap` is a local network diagnostic; it needs no server credentials, // so handle it before requiring a username/password. if matches!(cli.command, Some(Commands::Portmap)) { println!( "Testing automatic port mapping for TCP {}…", - cli.listener_port + resolved.listener_port ); - println!("{}", port_mapping::diagnose(cli.listener_port)); + println!("{}", port_mapping::diagnose(resolved.listener_port)); return Ok(()); } - let username = cli.username.ok_or_else(|| { + let username = resolved.username.clone().ok_or_else(|| { color_eyre::eyre::eyre!( - "Username required: use --username or set SOULSEEK_USERNAME env var" + "Username required: use --username, set SOULSEEK_USERNAME, or add it to config.toml" ) })?; - let password = cli.password.ok_or_else(|| { + let password = cli.password.clone().ok_or_else(|| { color_eyre::eyre::eyre!( "Password required: use --password or set SOULSEEK_PASSWORD env var" ) })?; - let (server_host, server_port) = parse_server_address(&cli.server)?; + let (server_host, server_port) = parse_server_address(&resolved.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(), + resolved.shared_dir.as_deref(), ) { Ok(dir) => dir.map(|path| path.display().to_string()), Err(e) => { @@ -66,8 +75,8 @@ fn main() -> Result<()> { username: username.clone(), password: password.clone(), server_address: PeerAddress::new(server_host.clone(), server_port), - enable_listen: !cli.disable_listener, - listen_port: cli.listener_port, + enable_listen: !resolved.disable_listener, + listen_port: resolved.listener_port, shared_directory: shared_directory.clone(), }; @@ -83,8 +92,8 @@ fn main() -> Result<()> { password, server_host, server_port, - enable_listener: !cli.disable_listener, - listener_port: cli.listener_port, + enable_listener: !resolved.disable_listener, + listener_port: resolved.listener_port, query, timeout, download_dir, @@ -111,9 +120,9 @@ fn main() -> Result<()> { Some(Commands::Portmap) => unreachable!(), None => run_default_tui( settings, - cli.download_dir, - cli.max_concurrent_downloads, - Duration::from_secs(cli.search_timeout), + resolved.download_dir.clone(), + resolved.max_concurrent_downloads, + Duration::from_secs(resolved.search_timeout), ), } } diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs new file mode 100644 index 0000000..e93a6c7 --- /dev/null +++ b/soulseek-rs/src/persist/config.rs @@ -0,0 +1,235 @@ +use color_eyre::Result; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Optional settings read from `config.toml`. Every field is optional so a +/// partial file (or none at all) is valid; unknown keys are ignored so newer +/// configs still load in older builds. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct FileConfig { + pub username: Option, + pub server: Option, + pub listener_port: Option, + pub disable_listener: Option, + pub download_dir: Option, + pub shared_dir: Option, + pub max_concurrent_downloads: Option, + pub search_timeout: Option, + /// 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, +} + +impl FileConfig { + /// 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 { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::default()); + } + Err(e) => { + return Err(color_eyre::eyre::eyre!( + "Cannot read {}: {e}", + path.display() + )); + } + }; + toml::from_str(&text).map_err(|e| { + color_eyre::eyre::eyre!("Malformed {}: {e}", path.display()) + }) + } + + /// Save to `path`, creating parent directories as needed. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, toml::to_string_pretty(self)?)?; + Ok(()) + } +} + +/// Fully-resolved settings after layering CLI (which already includes env via +/// clap) over the config file over built-in defaults. +#[derive(Debug, Clone, PartialEq)] +pub struct Resolved { + pub username: Option, + pub server: String, + pub listener_port: u16, + pub disable_listener: bool, + pub download_dir: String, + pub shared_dir: Option, + pub max_concurrent_downloads: usize, + pub search_timeout: u64, + pub password_cmd: Option, +} + +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; + +/// Layer CLI/env values over the config file over defaults. The +/// `--disable-listener` flag can only enable the disable (a bare flag has no +/// "explicitly off" form), so file `disable_listener = true` wins unless the +/// flag is passed. +pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { + Resolved { + username: cli.username.clone().or_else(|| file.username.clone()), + server: cli + .server + .clone() + .or_else(|| file.server.clone()) + .unwrap_or_else(|| DEFAULT_SERVER.to_string()), + listener_port: cli + .listener_port + .or(file.listener_port) + .unwrap_or(DEFAULT_LISTENER_PORT), + disable_listener: cli.disable_listener + || file.disable_listener.unwrap_or(false), + download_dir: cli + .download_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()), + max_concurrent_downloads: cli + .max_concurrent_downloads + .or(file.max_concurrent_downloads) + .unwrap_or(DEFAULT_MAX_CONCURRENT_DOWNLOADS), + search_timeout: cli + .search_timeout + .or(file.search_timeout) + .unwrap_or(DEFAULT_SEARCH_TIMEOUT), + password_cmd: file.password_cmd.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Cli; + + fn bare_cli() -> Cli { + Cli { + username: None, + password: None, + server: None, + disable_listener: false, + listener_port: None, + verbose: 0, + log_file: None, + command: None, + download_dir: None, + shared_dir: None, + max_concurrent_downloads: None, + search_timeout: None, + } + } + + #[test] + fn defaults_apply_when_cli_and_file_are_empty() { + 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 + ); + assert_eq!(resolved.search_timeout, DEFAULT_SEARCH_TIMEOUT); + assert!(!resolved.disable_listener); + assert_eq!(resolved.username, None); + } + + #[test] + fn file_values_override_defaults() { + let file = FileConfig { + username: Some("alice".into()), + server: Some("localhost:2242".into()), + listener_port: Some(4321), + disable_listener: Some(true), + download_dir: Some("/music".into()), + shared_dir: Some("/shared".into()), + max_concurrent_downloads: Some(2), + search_timeout: Some(30), + password_cmd: Some("pass show slsk".into()), + }; + let resolved = resolve(&bare_cli(), &file); + assert_eq!(resolved.username.as_deref(), Some("alice")); + assert_eq!(resolved.server, "localhost:2242"); + assert_eq!(resolved.listener_port, 4321); + assert!(resolved.disable_listener); + assert_eq!(resolved.download_dir, "/music"); + assert_eq!(resolved.shared_dir.as_deref(), Some("/shared")); + assert_eq!(resolved.max_concurrent_downloads, 2); + assert_eq!(resolved.search_timeout, 30); + assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk")); + } + + #[test] + fn cli_values_override_file_values() { + let mut cli = bare_cli(); + cli.username = Some("cli-user".into()); + cli.server = Some("cli-server:1".into()); + cli.listener_port = Some(1111); + cli.download_dir = Some("/cli-dl".into()); + let file = FileConfig { + username: Some("file-user".into()), + server: Some("file-server:2".into()), + listener_port: Some(2222), + download_dir: Some("/file-dl".into()), + ..FileConfig::default() + }; + let resolved = resolve(&cli, &file); + assert_eq!(resolved.username.as_deref(), Some("cli-user")); + assert_eq!(resolved.server, "cli-server:1"); + assert_eq!(resolved.listener_port, 1111); + assert_eq!(resolved.download_dir, "/cli-dl"); + } + + #[test] + fn missing_file_loads_as_empty_config() { + let dir = tempfile::tempdir().unwrap(); + let config = FileConfig::load(&dir.path().join("config.toml")).unwrap(); + assert_eq!(config, FileConfig::default()); + } + + #[test] + fn save_then_load_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("config.toml"); + let config = FileConfig { + username: Some("alice".into()), + server: Some("localhost:2242".into()), + listener_port: Some(2234), + max_concurrent_downloads: Some(3), + ..FileConfig::default() + }; + config.save(&path).unwrap(); + assert_eq!(FileConfig::load(&path).unwrap(), config); + } + + #[test] + fn unknown_keys_and_partial_files_are_tolerated() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "username = \"bob\"\nfuture_option = true\n") + .unwrap(); + let config = FileConfig::load(&path).unwrap(); + assert_eq!(config.username.as_deref(), Some("bob")); + assert_eq!(config.server, None); + } + + #[test] + fn malformed_file_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "username = [unclosed").unwrap(); + assert!(FileConfig::load(&path).is_err()); + } +} diff --git a/soulseek-rs/src/persist/mod.rs b/soulseek-rs/src/persist/mod.rs new file mode 100644 index 0000000..9fd0523 --- /dev/null +++ b/soulseek-rs/src/persist/mod.rs @@ -0,0 +1,8 @@ +//! Persistence for the TUI binary: config file, secrets, and state. +//! +//! Everything here is deliberately outside `soulseek-rs-lib` (which stays +//! zero-dependency); the lib keeps receiving plain values via +//! `ClientSettings`. + +pub mod config; +pub mod paths; diff --git a/soulseek-rs/src/persist/paths.rs b/soulseek-rs/src/persist/paths.rs new file mode 100644 index 0000000..53b655b --- /dev/null +++ b/soulseek-rs/src/persist/paths.rs @@ -0,0 +1,23 @@ +use directories::ProjectDirs; +use std::path::PathBuf; + +/// Platform-appropriate locations for the config file and state directory. +/// Overridable via `SOULSEEK_CONFIG_DIR` / `SOULSEEK_STATE_DIR` (used by the +/// e2e tests, and handy for portable installs). +pub fn config_file() -> Option { + if let Ok(dir) = std::env::var("SOULSEEK_CONFIG_DIR") { + return Some(PathBuf::from(dir).join("config.toml")); + } + project_dirs().map(|d| d.config_dir().join("config.toml")) +} + +pub fn state_dir() -> Option { + if let Ok(dir) = std::env::var("SOULSEEK_STATE_DIR") { + return Some(PathBuf::from(dir)); + } + project_dirs().map(|d| d.data_dir().join("state")) +} + +fn project_dirs() -> Option { + ProjectDirs::from("", "", "soulseek-rs") +}