use std::collections::HashMap;
use std::path::Path;
use crate::config::option::OptionValue;
use crate::request::request_group::DownloadOptions;
fn built_in_defaults() -> Vec<(&'static str, OptionValue)> {
vec![
("dir", OptionValue::Str(String::from("."))),
("max-concurrent-downloads", OptionValue::Usize(5)),
("max-connection-per-server", OptionValue::Usize(16)),
("min-split-size", OptionValue::Usize(1_048_576)), ("split", OptionValue::Usize(5)),
("max-overall-download-limit", OptionValue::Usize(0)), ("max-download-limit", OptionValue::Usize(0)),
("max-upload-limit", OptionValue::Usize(0)),
("continue", OptionValue::Bool(true)),
("remote-time", OptionValue::Bool(true)),
("reuse-uri", OptionValue::Bool(true)),
("allow-overwrite", OptionValue::Bool(true)),
(
"file-allocation",
OptionValue::Str(String::from("falloc")), ),
(
"mmap-threshold",
OptionValue::Usize(256 * 1024 * 1024), ),
("auto-save-interval", OptionValue::Usize(60)),
("check-certificate", OptionValue::Bool(true)),
("bt-max-peers", OptionValue::Usize(128)),
("bt-request-peer-speed-limit", OptionValue::Usize(0)),
("seed-time", OptionValue::Usize(0)),
("seed-ratio", OptionValue::Float(0.0)),
("rpc-listen-port", OptionValue::Usize(6800)),
("rpc-secret", OptionValue::Str(String::new())),
("quiet", OptionValue::Bool(false)),
(
"console-log-level",
OptionValue::Str(String::from("notice")),
),
]
}
pub struct OptionHandler {
options: HashMap<String, OptionValue>,
defaults: HashMap<String, OptionValue>,
}
impl OptionHandler {
pub fn new() -> Self {
let defaults = built_in_defaults();
let mut options = HashMap::with_capacity(defaults.len());
let mut defaults_map = HashMap::with_capacity(defaults.len());
for (key, value) in defaults {
options.insert(key.to_string(), value.clone());
defaults_map.insert(key.to_string(), value);
}
Self {
options,
defaults: defaults_map,
}
}
pub fn set(&mut self, key: &str, value: OptionValue) {
self.options.insert(key.to_string(), value);
}
pub fn get(&self, key: &str) -> &OptionValue {
self.options
.get(key)
.unwrap_or_else(|| self.defaults.get(key).unwrap_or(&OptionValue::None))
}
pub fn apply_args(&mut self, args: &[String]) {
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if !arg.starts_with('-') || arg == "--" {
i += 1;
continue;
}
if let Some((key, value)) = Self::parse_kv_arg(arg) {
if let Some(parsed) = Self::detect_value_type(value.trim()) {
tracing::debug!(key, value = ?parsed, "CLI arg applied");
self.set(key, parsed);
}
i += 1;
continue;
}
if let Some(key) = arg.strip_prefix("--no-") {
self.set(key, OptionValue::Bool(false));
i += 1;
continue;
}
if let Some(key) = arg.strip_prefix("--") {
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
let value = &args[i + 1];
if let Some(parsed) = Self::detect_value_type(value) {
self.set(key, parsed);
}
i += 2;
continue;
} else {
self.set(key, OptionValue::Bool(true));
i += 1;
continue;
}
}
if arg == "-o" && i + 1 < args.len() {
let next = &args[i + 1];
if let Some((key, value)) = next.split_once('=')
&& let Some(parsed) = Self::detect_value_type(value.trim())
{
self.set(key, parsed);
}
i += 2;
continue;
}
i += 1;
}
}
fn parse_kv_arg(arg: &str) -> Option<(&str, &str)> {
let stripped = arg.strip_prefix("--")?;
if let Some((k, v)) = stripped.split_once('=') {
return Some((k, v));
}
if let Some((k, v)) = stripped.split_once(':') {
return Some((k, v));
}
None
}
fn detect_value_type(value: &str) -> Option<OptionValue> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Some(OptionValue::None);
}
if trimmed == "true" || trimmed == "yes" || trimmed == "on" {
return Some(OptionValue::Bool(true));
}
if trimmed == "false" || trimmed == "no" || trimmed == "off" {
return Some(OptionValue::Bool(false));
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let inner = &trimmed[1..trimmed.len() - 1];
let items: Vec<String> = inner
.split(',')
.map(|s| {
let item = s.trim();
if (item.starts_with('\'') && item.ends_with('\''))
|| (item.starts_with('"') && item.ends_with('"'))
{
&item[1..item.len() - 1]
} else {
item
}
.to_string()
})
.filter(|s| !s.is_empty())
.collect();
return Some(OptionValue::List(items));
}
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
return Some(OptionValue::Str(trimmed[1..trimmed.len() - 1].to_string()));
}
if let Some(neg) = trimmed.strip_prefix('-')
&& neg.parse::<i64>().is_ok()
{
return Some(OptionValue::Int(-neg.parse::<i64>().unwrap()));
}
if trimmed.parse::<usize>().is_ok() {
return Some(OptionValue::Usize(trimmed.parse::<usize>().unwrap()));
}
if trimmed.parse::<f64>().is_ok() {
return Some(OptionValue::Float(trimmed.parse::<f64>().unwrap()));
}
Some(OptionValue::Str(trimmed.to_string()))
}
pub fn load_config_file(&mut self, path: &Path) -> Result<(), String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?;
for (line_num, raw_line) in content.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value_str)) = line.split_once('=') else {
tracing::warn!(
path = %path.display(),
line = line_num + 1,
content = raw_line,
"Skipping invalid config line (no '=' found)"
);
continue;
};
let key = key.trim();
let value_str = value_str.trim();
if key.is_empty() {
tracing::warn!(
path = %path.display(),
line = line_num + 1,
"Skipping config line with empty key"
);
continue;
}
match Self::detect_value_type(value_str) {
Some(parsed) => {
tracing::debug!(
key,
value = ?parsed,
source = %path.display(),
"Config option loaded"
);
self.set(key, parsed);
}
None => {
tracing::warn!(
key,
line = line_num + 1,
source = %path.display(),
"Failed to parse config value"
);
}
}
}
Ok(())
}
pub fn to_download_options(&self) -> DownloadOptions {
let get_usize = |key: &str| -> Option<u16> {
let v = self.get(key).as_usize();
if v > 0 { Some(v as u16) } else { None }
};
let get_u64 = |key: &str| -> Option<u64> {
let v = self.get(key).as_usize();
if v > 0 { Some(v as u64) } else { None }
};
let get_str = |key: &str| -> Option<String> {
self.get(key)
.as_str()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
};
DownloadOptions {
split: get_usize("split"),
max_connection_per_server: get_usize("max-connection-per-server"),
max_download_limit: get_u64("max-download-limit"),
max_upload_limit: get_u64("max-upload-limit"),
dir: get_str("dir"),
out: get_str("out"),
seed_time: get_u64("seed-time"),
seed_ratio: {
let r = self.get("seed-ratio").as_f64().unwrap_or(0.0);
if r > 0.0 { Some(r) } else { None }
},
checksum: None,
cookie_file: get_str("cookie-file"),
cookies: get_str("cookies"),
bt_force_encrypt: self.get("bt-force-encrypt").as_bool().unwrap_or(false),
bt_require_crypto: self.get("bt-require-crypto").as_bool().unwrap_or(false),
enable_dht: self.get("enable-dht").as_bool().unwrap_or(true),
dht_listen_port: get_usize("dht-listen-port"),
dht_entry_point: {
let v = self.get("dht-entry-point").as_str().unwrap_or("");
if v.is_empty() {
None
} else {
Some(
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
)
}
},
enable_public_trackers: self.get("enable-public-trackers").as_bool().unwrap_or(true),
bt_piece_selection_strategy: self
.get("bt-piece-selection-strategy")
.as_str()
.unwrap_or("")
.to_string(),
bt_endgame_threshold: self.get("bt-endgame-threshold").as_usize() as u32,
max_retries: self.get("max-tries").as_usize() as u32,
retry_wait: self.get("retry-wait").as_usize() as u64,
http_proxy: get_str("http-proxy"),
all_proxy: get_str("all-proxy"),
https_proxy: get_str("https-proxy"),
ftp_proxy: get_str("ftp-proxy"),
no_proxy: get_str("no-proxy"),
dht_file_path: get_str("dht-file-path"),
bt_max_upload_slots: {
let v = self.get("bt-max-upload-slots").as_usize();
if v > 0 { Some(v as u32) } else { None }
},
bt_optimistic_unchoke_interval: {
let v = self.get("bt-optimistic-unchoke-interval").as_usize();
if v > 0 { Some(v as u64) } else { None }
},
bt_snubbed_timeout: {
let v = self.get("bt-snubbed-timeout").as_usize();
if v > 0 { Some(v as u64) } else { None }
},
bt_prioritize_piece: self
.get("bt-prioritize-piece")
.as_str()
.unwrap_or("")
.to_string(),
enable_utp: self.get("enable-utp").as_bool().unwrap_or(false),
utp_listen_port: get_usize("utp-listen-port"),
header: {
self.get("header")
.as_str()
.unwrap_or("")
.split('\n')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
},
user_agent: get_str("user-agent"),
referer: get_str("referer"),
file_allocation: get_str("file-allocation"),
mmap_threshold: get_u64("mmap-threshold"),
secure_falloc: self.get("secure-falloc").as_bool().unwrap_or(false),
}
}
pub fn to_map(&self) -> HashMap<String, OptionValue> {
let mut map = self.defaults.clone();
for (k, v) in &self.options {
map.insert(k.clone(), v.clone());
}
map
}
pub fn default_count(&self) -> usize {
self.defaults.len()
}
pub fn is_explicitly_set(&self, key: &str) -> bool {
self.options.contains_key(key)
}
pub fn reset_to_default(&mut self, key: &str) {
self.options.remove(key);
}
}
impl Default for OptionHandler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_defaults_populated() {
let handler = OptionHandler::new();
let expected_count = built_in_defaults().len();
assert_eq!(handler.default_count(), expected_count);
assert!(handler.default_count() > 0);
assert_eq!(handler.get("dir").as_str().unwrap_or(""), ".");
assert_eq!(handler.get("split").as_usize(), 5);
assert_eq!(handler.get("max-concurrent-downloads").as_usize(), 5);
assert_eq!(handler.get("max-connection-per-server").as_usize(), 16);
assert_eq!(handler.get("min-split-size").as_usize(), 1_048_576);
assert!(handler.get("continue").as_bool().unwrap_or(false));
assert!(!handler.get("quiet").as_bool().unwrap_or(false));
assert_eq!(handler.get("seed-ratio").as_f64().unwrap_or(0.0), 0.0);
assert_eq!(handler.get("rpc-listen-port").as_usize(), 6800);
assert_eq!(
handler.get("console-log-level").as_str().unwrap_or(""),
"notice"
);
}
#[test]
fn test_set_get_roundtrip() {
let mut handler = OptionHandler::new();
handler.set("dir", OptionValue::Str("/tmp/downloads".into()));
assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/tmp/downloads");
handler.set("split", OptionValue::Usize(16));
assert_eq!(handler.get("split").as_usize(), 16);
handler.set("seed-ratio", OptionValue::Float(2.5));
assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 2.5).abs() < f64::EPSILON);
handler.set("quiet", OptionValue::Bool(true));
assert!(handler.get("quiet").as_bool().unwrap_or(false));
handler.set(
"header",
OptionValue::List(vec!["X-Custom: foo".into(), "X-Bar: baz".into()]),
);
assert_eq!(handler.get("header").as_str_vec().len(), 2);
handler.set("split", OptionValue::Usize(32));
assert_eq!(handler.get("split").as_usize(), 32);
assert!(handler.get("nonexistent-key").is_none());
}
#[test]
#[allow(clippy::approx_constant)]
fn test_load_config_file() {
let mut handler = OptionHandler::new();
let config_content = r#"
# This is a comment
dir="/home/user/downloads"
split=16
max-connection-per-server=8
quiet=true
seed-ratio=1.5
custom-list=['header1', 'header2', 'header3']
bool-flag=yes
number-key=42
float-key=3.14
# Another comment
allow-overwrite=false
"#;
let tmp_dir = std::env::temp_dir();
let config_path = tmp_dir.join(format!("aria2_test_config_{}.aria2rc", std::process::id()));
std::fs::write(&config_path, config_content).expect("Failed to write temp config");
let result = handler.load_config_file(&config_path);
assert!(
result.is_ok(),
"load_config_file should succeed: {:?}",
result.err()
);
assert_eq!(
handler.get("dir").as_str().unwrap_or(""),
"/home/user/downloads"
);
assert_eq!(handler.get("split").as_usize(), 16);
assert_eq!(handler.get("max-connection-per-server").as_usize(), 8);
assert!(handler.get("quiet").as_bool().unwrap_or(false));
assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 1.5).abs() < f64::EPSILON);
assert!(!handler.get("allow-overwrite").as_bool().unwrap_or(true));
let list_val = handler.get("custom-list");
assert_eq!(list_val.as_str_vec().len(), 3);
assert_eq!(list_val.as_str_vec()[0], "header1");
assert!(handler.get("bool-flag").as_bool().unwrap_or(false)); assert_eq!(handler.get("number-key").as_usize(), 42);
let float_val = handler.get("float-key").as_f64().unwrap_or(0.0);
assert!((float_val - 3.14).abs() < f64::EPSILON);
assert_eq!(handler.get("rpc-listen-port").as_usize(), 6800);
let _ = std::fs::remove_file(&config_path);
}
#[test]
fn test_apply_args_overrides_config() {
let mut handler = OptionHandler::new();
let config_content = r#"
dir=/config/dir
split=4
quiet=false
"#;
let tmp_dir = std::env::temp_dir();
let config_path = tmp_dir.join(format!(
"aria2_test_override_{}.aria2rc",
std::process::id()
));
std::fs::write(&config_path, config_content).expect("Failed to write config");
handler
.load_config_file(&config_path)
.expect("Should load config");
assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/config/dir");
assert_eq!(handler.get("split").as_usize(), 4);
assert!(!handler.get("quiet").as_bool().unwrap_or(false));
let cli_args: Vec<String> = vec![
"--dir=/cli/dir".to_string(),
"--split=12".to_string(),
"--quiet".to_string(), "--max-connection-per-server=8".to_string(),
"--seed-ratio=2.0".to_string(),
"--no-continue".to_string(), ];
handler.apply_args(&cli_args);
assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/cli/dir");
assert_eq!(handler.get("split").as_usize(), 12);
assert!(handler.get("quiet").as_bool().unwrap_or(false)); assert_eq!(handler.get("max-connection-per-server").as_usize(), 8);
assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 2.0).abs() < f64::EPSILON);
assert!(!handler.get("continue").as_bool().unwrap_or(true));
let _ = std::fs::remove_file(&config_path);
}
#[test]
fn test_to_download_options() {
let mut handler = OptionHandler::new();
handler.set("split", OptionValue::Usize(8));
handler.set("max-connection-per-server", OptionValue::Usize(4));
handler.set("max-download-limit", OptionValue::Usize(102400));
handler.set("max-upload-limit", OptionValue::Usize(51200));
handler.set("dir", OptionValue::Str("/data".to_string()));
handler.set("out", OptionValue::Str("output.bin".to_string()));
handler.set("seed-time", OptionValue::Usize(300));
handler.set("seed-ratio", OptionValue::Float(2.0));
let opts = handler.to_download_options();
assert_eq!(opts.split, Some(8));
assert_eq!(opts.max_connection_per_server, Some(4));
assert_eq!(opts.max_download_limit, Some(102400));
assert_eq!(opts.max_upload_limit, Some(51200));
assert_eq!(opts.dir, Some("/data".to_string()));
assert_eq!(opts.out, Some("output.bin".to_string()));
assert_eq!(opts.seed_time, Some(300));
assert_eq!(opts.seed_ratio, Some(2.0));
let handler2 = OptionHandler::new();
let opts2 = handler2.to_download_options();
assert_eq!(opts2.split, Some(5)); assert_eq!(opts2.max_connection_per_server, Some(16)); assert_eq!(opts2.dir, Some(".".to_string())); assert_eq!(opts2.out, None);
handler.reset_to_default("split");
assert_eq!(handler.get("split").as_usize(), 5); assert!(!handler.is_explicitly_set("split"));
}
#[test]
#[allow(clippy::approx_constant)]
fn test_detect_value_type_edge_cases() {
assert_eq!(
OptionHandler::detect_value_type("true"),
Some(OptionValue::Bool(true))
);
assert_eq!(
OptionHandler::detect_value_type("false"),
Some(OptionValue::Bool(false))
);
assert_eq!(
OptionHandler::detect_value_type("yes"),
Some(OptionValue::Bool(true))
);
assert_eq!(
OptionHandler::detect_value_type("no"),
Some(OptionValue::Bool(false))
);
assert_eq!(
OptionHandler::detect_value_type("42"),
Some(OptionValue::Usize(42))
);
assert_eq!(
OptionHandler::detect_value_type("-10"),
Some(OptionValue::Int(-10))
);
let detected = OptionHandler::detect_value_type("3.14159")
.unwrap()
.as_f64()
.unwrap_or(0.0);
assert!((detected - 3.14159).abs() < 0.001); assert_eq!(
OptionHandler::detect_value_type("\"quoted string\""),
Some(OptionValue::Str("quoted string".into()))
);
assert_eq!(
OptionHandler::detect_value_type("['a','b','c']"),
Some(OptionValue::List(vec!["a".into(), "b".into(), "c".into()]))
);
assert_eq!(
OptionHandler::detect_value_type(""),
Some(OptionValue::None)
);
assert_eq!(
OptionHandler::detect_value_type("plain_text"),
Some(OptionValue::Str("plain_text".into()))
);
}
#[test]
fn test_option_value_display() {
assert_eq!(OptionValue::Bool(true).to_string(), "true");
assert_eq!(OptionValue::Usize(42).to_string(), "42");
assert_eq!(OptionValue::Int(-10).to_string(), "-10");
assert_eq!(
format!("{:.2}", {
#[allow(clippy::approx_constant)]
OptionValue::Float(3.14).to_string().parse::<f64>().unwrap()
}),
"3.14"
); assert_eq!(OptionValue::Str("hello".to_string()).to_string(), "hello");
assert_eq!(
OptionValue::List(vec!["a".into(), "b".into()]).to_string(),
"a,b"
);
assert_eq!(OptionValue::None.to_string(), "");
}
#[test]
fn test_to_map_includes_all() {
let mut handler = OptionHandler::new();
handler.set("custom-key", OptionValue::Str("custom-value".into()));
let map = handler.to_map();
assert!(map.contains_key("dir"));
assert!(map.contains_key("split"));
assert!(map.contains_key("custom-key"));
assert_eq!(
map.get("custom-key").unwrap().as_str().unwrap_or(""),
"custom-value"
);
assert!(map.len() >= built_in_defaults().len());
}
}