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:
Generated
+171
@@ -483,6 +483,27 @@ dependencies = [
|
|||||||
"crypto-common",
|
"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]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -566,6 +587,12 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -644,6 +671,17 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -846,6 +884,16 @@ version = "0.3.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
|
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]]
|
[[package]]
|
||||||
name = "indoc"
|
name = "indoc"
|
||||||
version = "2.0.7"
|
version = "2.0.7"
|
||||||
@@ -935,6 +983,15 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "line-clipping"
|
name = "line-clipping"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -1123,6 +1180,12 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
@@ -1484,6 +1547,17 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -1595,6 +1669,28 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -1670,10 +1766,15 @@ version = "5.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
|
"directories",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"igd-next",
|
"igd-next",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"soulseek-rs-lib",
|
"soulseek-rs-lib",
|
||||||
|
"tempfile",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1752,6 +1853,19 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "termina"
|
name = "termina"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -1918,6 +2032,45 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -2226,6 +2379,18 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
@@ -2329,3 +2494,9 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|||||||
@@ -24,3 +24,10 @@ ratatui = "0.30.2"
|
|||||||
color-eyre = "0.6.5"
|
color-eyre = "0.6.5"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
igd-next = "0.17"
|
igd-next = "0.17"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.9"
|
||||||
|
directories = "6"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
+15
-25
@@ -16,19 +16,16 @@ pub struct Cli {
|
|||||||
#[arg(short, long, env = "SOULSEEK_PASSWORD")]
|
#[arg(short, long, env = "SOULSEEK_PASSWORD")]
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
|
|
||||||
#[arg(
|
/// Server address as host:port (default: server.slsknet.org:2416)
|
||||||
short,
|
#[arg(short, long, env = "SOULSEEK_SERVER")]
|
||||||
long,
|
pub server: Option<String>,
|
||||||
env = "SOULSEEK_SERVER",
|
|
||||||
default_value = "server.slsknet.org:2416"
|
|
||||||
)]
|
|
||||||
pub server: String,
|
|
||||||
|
|
||||||
#[arg(long, env = "DISABLE_LISTENER")]
|
#[arg(long, env = "DISABLE_LISTENER")]
|
||||||
pub disable_listener: bool,
|
pub disable_listener: bool,
|
||||||
|
|
||||||
#[arg(short, long, env = "LISTENER_PORT", default_value = "2234")]
|
/// Port to accept peer connections on (default: 2234)
|
||||||
pub listener_port: u16,
|
#[arg(short, long, env = "LISTENER_PORT")]
|
||||||
|
pub listener_port: Option<u16>,
|
||||||
|
|
||||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||||
pub verbose: u8,
|
pub verbose: u8,
|
||||||
@@ -43,13 +40,9 @@ pub struct Cli {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Option<Commands>,
|
pub command: Option<Commands>,
|
||||||
|
|
||||||
#[arg(
|
/// Directory downloads are saved to (default: ~/Downloads)
|
||||||
short,
|
#[arg(short, long, env = "SOULSEEK_DOWNLOAD_DIR")]
|
||||||
long,
|
pub download_dir: Option<String>,
|
||||||
env = "SOULSEEK_DOWNLOAD_DIR",
|
|
||||||
default_value = "~/Downloads"
|
|
||||||
)]
|
|
||||||
pub download_dir: String,
|
|
||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
@@ -58,16 +51,13 @@ pub struct Cli {
|
|||||||
)]
|
)]
|
||||||
pub shared_dir: Option<String>,
|
pub shared_dir: Option<String>,
|
||||||
|
|
||||||
#[arg(
|
/// Maximum simultaneous downloads (default: 5)
|
||||||
short = 'c',
|
#[arg(short = 'c', long, env = "MAX_CONCURRENT_DOWNLOADS")]
|
||||||
long,
|
pub max_concurrent_downloads: Option<usize>,
|
||||||
env = "MAX_CONCURRENT_DOWNLOADS",
|
|
||||||
default_value = "5"
|
|
||||||
)]
|
|
||||||
pub max_concurrent_downloads: usize,
|
|
||||||
|
|
||||||
#[arg(long, default_value = "10")]
|
/// Seconds a search stays active (default: 10)
|
||||||
pub search_timeout: u64,
|
#[arg(long)]
|
||||||
|
pub search_timeout: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
|
|||||||
+23
-14
@@ -2,6 +2,7 @@ mod cli;
|
|||||||
mod config;
|
mod config;
|
||||||
mod directories;
|
mod directories;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod persist;
|
||||||
mod port_mapping;
|
mod port_mapping;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
@@ -25,35 +26,43 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
init_logging(&cli);
|
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,
|
// `portmap` is a local network diagnostic; it needs no server credentials,
|
||||||
// so handle it before requiring a username/password.
|
// so handle it before requiring a username/password.
|
||||||
if matches!(cli.command, Some(Commands::Portmap)) {
|
if matches!(cli.command, Some(Commands::Portmap)) {
|
||||||
println!(
|
println!(
|
||||||
"Testing automatic port mapping for TCP {}…",
|
"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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let username = cli.username.ok_or_else(|| {
|
let username = resolved.username.clone().ok_or_else(|| {
|
||||||
color_eyre::eyre::eyre!(
|
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!(
|
color_eyre::eyre::eyre!(
|
||||||
"Password required: use --password or set SOULSEEK_PASSWORD env var"
|
"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
|
// Resolve the optional shared/upload directory up front; a misconfigured
|
||||||
// one is a warning, not a fatal error (the client just shares nothing).
|
// one is a warning, not a fatal error (the client just shares nothing).
|
||||||
let shared_directory = match directories::resolve_shared_directory(
|
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()),
|
Ok(dir) => dir.map(|path| path.display().to_string()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -66,8 +75,8 @@ fn main() -> Result<()> {
|
|||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
password: password.clone(),
|
password: password.clone(),
|
||||||
server_address: PeerAddress::new(server_host.clone(), server_port),
|
server_address: PeerAddress::new(server_host.clone(), server_port),
|
||||||
enable_listen: !cli.disable_listener,
|
enable_listen: !resolved.disable_listener,
|
||||||
listen_port: cli.listener_port,
|
listen_port: resolved.listener_port,
|
||||||
shared_directory: shared_directory.clone(),
|
shared_directory: shared_directory.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,8 +92,8 @@ fn main() -> Result<()> {
|
|||||||
password,
|
password,
|
||||||
server_host,
|
server_host,
|
||||||
server_port,
|
server_port,
|
||||||
enable_listener: !cli.disable_listener,
|
enable_listener: !resolved.disable_listener,
|
||||||
listener_port: cli.listener_port,
|
listener_port: resolved.listener_port,
|
||||||
query,
|
query,
|
||||||
timeout,
|
timeout,
|
||||||
download_dir,
|
download_dir,
|
||||||
@@ -111,9 +120,9 @@ fn main() -> Result<()> {
|
|||||||
Some(Commands::Portmap) => unreachable!(),
|
Some(Commands::Portmap) => unreachable!(),
|
||||||
None => run_default_tui(
|
None => run_default_tui(
|
||||||
settings,
|
settings,
|
||||||
cli.download_dir,
|
resolved.download_dir.clone(),
|
||||||
cli.max_concurrent_downloads,
|
resolved.max_concurrent_downloads,
|
||||||
Duration::from_secs(cli.search_timeout),
|
Duration::from_secs(resolved.search_timeout),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user