feat: config.toml settings layer (CLI > env > file > defaults)

New persist module in the binary crate (lib stays zero-dep): FileConfig
loaded from a platform-appropriate config path via the directories crate,
with resolution precedence CLI/env (clap) > config.toml > built-in
defaults. Clap defaults move into the resolver so the file layer can sit
between.
This commit is contained in:
michel
2026-07-20 20:10:33 +02:00
parent 089a9da31a
commit 86e3d8c58f
7 changed files with 482 additions and 39 deletions
+15 -25
View File
@@ -16,19 +16,16 @@ pub struct Cli {
#[arg(short, long, env = "SOULSEEK_PASSWORD")]
pub password: Option<String>,
#[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<String>,
#[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<u16>,
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
@@ -43,13 +40,9 @@ pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
#[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<String>,
#[arg(
long,
@@ -58,16 +51,13 @@ pub struct Cli {
)]
pub shared_dir: Option<String>,
#[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<usize>,
#[arg(long, default_value = "10")]
pub search_timeout: u64,
/// Seconds a search stays active (default: 10)
#[arg(long)]
pub search_timeout: Option<u64>,
}
#[derive(Subcommand, Debug)]
+23 -14
View File
@@ -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),
),
}
}
+235
View File
@@ -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<String>,
pub server: Option<String>,
pub listener_port: Option<u16>,
pub disable_listener: Option<bool>,
pub download_dir: Option<String>,
pub shared_dir: Option<String>,
pub max_concurrent_downloads: Option<usize>,
pub search_timeout: Option<u64>,
/// 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>,
}
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<Self> {
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<String>,
pub server: String,
pub listener_port: u16,
pub disable_listener: bool,
pub download_dir: String,
pub shared_dir: Option<String>,
pub max_concurrent_downloads: usize,
pub search_timeout: u64,
pub password_cmd: Option<String>,
}
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());
}
}
+8
View File
@@ -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;
+23
View File
@@ -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<PathBuf> {
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<PathBuf> {
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> {
ProjectDirs::from("", "", "soulseek-rs")
}