1use std::collections::HashMap;
23use std::path::Path;
24
25use crate::config::option::OptionValue;
26use crate::request::request_group::DownloadOptions;
27
28fn built_in_defaults() -> Vec<(&'static str, OptionValue)> {
41 vec![
42 ("dir", OptionValue::Str(String::from("."))),
43 ("max-concurrent-downloads", OptionValue::Usize(5)),
44 ("max-connection-per-server", OptionValue::Usize(16)),
45 ("min-split-size", OptionValue::Usize(1_048_576)), ("split", OptionValue::Usize(5)),
47 ("max-overall-download-limit", OptionValue::Usize(0)), ("max-download-limit", OptionValue::Usize(0)),
49 ("max-upload-limit", OptionValue::Usize(0)),
50 ("continue", OptionValue::Bool(true)),
51 ("remote-time", OptionValue::Bool(true)),
52 ("reuse-uri", OptionValue::Bool(true)),
53 ("allow-overwrite", OptionValue::Bool(true)),
54 (
55 "file-allocation",
56 OptionValue::Str(String::from("falloc")), ),
58 (
59 "mmap-threshold",
60 OptionValue::Usize(256 * 1024 * 1024), ),
62 ("auto-save-interval", OptionValue::Usize(60)),
63 ("check-certificate", OptionValue::Bool(true)),
64 ("bt-max-peers", OptionValue::Usize(128)),
65 ("bt-request-peer-speed-limit", OptionValue::Usize(0)),
66 ("seed-time", OptionValue::Usize(0)),
67 ("seed-ratio", OptionValue::Float(0.0)),
68 ("rpc-listen-port", OptionValue::Usize(6800)),
69 ("rpc-secret", OptionValue::Str(String::new())),
70 ("quiet", OptionValue::Bool(false)),
71 (
72 "console-log-level",
73 OptionValue::Str(String::from("notice")),
74 ),
75 ]
76}
77
78pub struct OptionHandler {
104 options: HashMap<String, OptionValue>,
106 defaults: HashMap<String, OptionValue>,
108}
109
110impl OptionHandler {
111 pub fn new() -> Self {
117 let defaults = built_in_defaults();
118 let mut options = HashMap::with_capacity(defaults.len());
119 let mut defaults_map = HashMap::with_capacity(defaults.len());
120
121 for (key, value) in defaults {
122 options.insert(key.to_string(), value.clone());
123 defaults_map.insert(key.to_string(), value);
124 }
125
126 Self {
127 options,
128 defaults: defaults_map,
129 }
130 }
131
132 pub fn set(&mut self, key: &str, value: OptionValue) {
137 self.options.insert(key.to_string(), value);
138 }
139
140 pub fn get(&self, key: &str) -> &OptionValue {
145 self.options
146 .get(key)
147 .unwrap_or_else(|| self.defaults.get(key).unwrap_or(&OptionValue::None))
148 }
149
150 pub fn apply_args(&mut self, args: &[String]) {
161 let mut i = 0;
162 while i < args.len() {
163 let arg = &args[i];
164
165 if !arg.starts_with('-') || arg == "--" {
167 i += 1;
168 continue;
169 }
170
171 if let Some((key, value)) = Self::parse_kv_arg(arg) {
173 if let Some(parsed) = Self::detect_value_type(value.trim()) {
174 tracing::debug!(key, value = ?parsed, "CLI arg applied");
175 self.set(key, parsed);
176 }
177 i += 1;
178 continue;
179 }
180
181 if let Some(key) = arg.strip_prefix("--no-") {
183 self.set(key, OptionValue::Bool(false));
184 i += 1;
185 continue;
186 }
187
188 if let Some(key) = arg.strip_prefix("--") {
190 if i + 1 < args.len() && !args[i + 1].starts_with('-') {
191 let value = &args[i + 1];
192 if let Some(parsed) = Self::detect_value_type(value) {
193 self.set(key, parsed);
194 }
195 i += 2;
196 continue;
197 } else {
198 self.set(key, OptionValue::Bool(true));
200 i += 1;
201 continue;
202 }
203 }
204
205 if arg == "-o" && i + 1 < args.len() {
207 let next = &args[i + 1];
208 if let Some((key, value)) = next.split_once('=')
209 && let Some(parsed) = Self::detect_value_type(value.trim())
210 {
211 self.set(key, parsed);
212 }
213 i += 2;
214 continue;
215 }
216
217 i += 1;
218 }
219 }
220
221 fn parse_kv_arg(arg: &str) -> Option<(&str, &str)> {
223 let stripped = arg.strip_prefix("--")?;
224 if let Some((k, v)) = stripped.split_once('=') {
225 return Some((k, v));
226 }
227 if let Some((k, v)) = stripped.split_once(':') {
228 return Some((k, v));
229 }
230 None
231 }
232
233 fn detect_value_type(value: &str) -> Option<OptionValue> {
243 let trimmed = value.trim();
244
245 if trimmed.is_empty() {
247 return Some(OptionValue::None);
248 }
249
250 if trimmed == "true" || trimmed == "yes" || trimmed == "on" {
252 return Some(OptionValue::Bool(true));
253 }
254 if trimmed == "false" || trimmed == "no" || trimmed == "off" {
255 return Some(OptionValue::Bool(false));
256 }
257
258 if trimmed.starts_with('[') && trimmed.ends_with(']') {
260 let inner = &trimmed[1..trimmed.len() - 1];
261 let items: Vec<String> = inner
262 .split(',')
263 .map(|s| {
264 let item = s.trim();
265 if (item.starts_with('\'') && item.ends_with('\''))
267 || (item.starts_with('"') && item.ends_with('"'))
268 {
269 &item[1..item.len() - 1]
270 } else {
271 item
272 }
273 .to_string()
274 })
275 .filter(|s| !s.is_empty())
276 .collect();
277 return Some(OptionValue::List(items));
278 }
279
280 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
282 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
283 {
284 return Some(OptionValue::Str(trimmed[1..trimmed.len() - 1].to_string()));
285 }
286
287 if let Some(neg) = trimmed.strip_prefix('-')
289 && neg.parse::<i64>().is_ok()
290 {
291 return Some(OptionValue::Int(-neg.parse::<i64>().unwrap()));
292 }
293
294 if trimmed.parse::<usize>().is_ok() {
296 return Some(OptionValue::Usize(trimmed.parse::<usize>().unwrap()));
297 }
298
299 if trimmed.parse::<f64>().is_ok() {
301 return Some(OptionValue::Float(trimmed.parse::<f64>().unwrap()));
302 }
303
304 Some(OptionValue::Str(trimmed.to_string()))
306 }
307
308 pub fn load_config_file(&mut self, path: &Path) -> Result<(), String> {
333 let content = std::fs::read_to_string(path)
334 .map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?;
335
336 for (line_num, raw_line) in content.lines().enumerate() {
337 let line = raw_line.trim();
338
339 if line.is_empty() || line.starts_with('#') {
341 continue;
342 }
343
344 let Some((key, value_str)) = line.split_once('=') else {
346 tracing::warn!(
347 path = %path.display(),
348 line = line_num + 1,
349 content = raw_line,
350 "Skipping invalid config line (no '=' found)"
351 );
352 continue;
353 };
354
355 let key = key.trim();
356 let value_str = value_str.trim();
357
358 if key.is_empty() {
359 tracing::warn!(
360 path = %path.display(),
361 line = line_num + 1,
362 "Skipping config line with empty key"
363 );
364 continue;
365 }
366
367 match Self::detect_value_type(value_str) {
369 Some(parsed) => {
370 tracing::debug!(
371 key,
372 value = ?parsed,
373 source = %path.display(),
374 "Config option loaded"
375 );
376 self.set(key, parsed);
377 }
378 None => {
379 tracing::warn!(
380 key,
381 line = line_num + 1,
382 source = %path.display(),
383 "Failed to parse config value"
384 );
385 }
386 }
387 }
388
389 Ok(())
390 }
391
392 pub fn to_download_options(&self) -> DownloadOptions {
398 let get_usize = |key: &str| -> Option<u16> {
399 let v = self.get(key).as_usize();
400 if v > 0 { Some(v as u16) } else { None }
401 };
402 let get_u64 = |key: &str| -> Option<u64> {
403 let v = self.get(key).as_usize();
404 if v > 0 { Some(v as u64) } else { None }
405 };
406 let get_str = |key: &str| -> Option<String> {
407 self.get(key)
408 .as_str()
409 .map(|s| s.to_string())
410 .filter(|s| !s.is_empty())
411 };
412
413 DownloadOptions {
414 split: get_usize("split"),
415 max_connection_per_server: get_usize("max-connection-per-server"),
416 max_download_limit: get_u64("max-download-limit"),
417 max_upload_limit: get_u64("max-upload-limit"),
418 dir: get_str("dir"),
419 out: get_str("out"),
420 seed_time: get_u64("seed-time"),
421 seed_ratio: {
422 let r = self.get("seed-ratio").as_f64().unwrap_or(0.0);
423 if r > 0.0 { Some(r) } else { None }
424 },
425 checksum: None,
426 cookie_file: get_str("cookie-file"),
427 cookies: get_str("cookies"),
428 bt_force_encrypt: self.get("bt-force-encrypt").as_bool().unwrap_or(false),
429 bt_require_crypto: self.get("bt-require-crypto").as_bool().unwrap_or(false),
430 enable_dht: self.get("enable-dht").as_bool().unwrap_or(true),
431 dht_listen_port: get_usize("dht-listen-port"),
432 dht_entry_point: {
433 let v = self.get("dht-entry-point").as_str().unwrap_or("");
434 if v.is_empty() {
435 None
436 } else {
437 Some(
438 v.split(',')
439 .map(|s| s.trim().to_string())
440 .filter(|s| !s.is_empty())
441 .collect(),
442 )
443 }
444 },
445 enable_public_trackers: self.get("enable-public-trackers").as_bool().unwrap_or(true),
446 bt_piece_selection_strategy: self
447 .get("bt-piece-selection-strategy")
448 .as_str()
449 .unwrap_or("")
450 .to_string(),
451 bt_endgame_threshold: self.get("bt-endgame-threshold").as_usize() as u32,
452 max_retries: self.get("max-tries").as_usize() as u32,
453 retry_wait: self.get("retry-wait").as_usize() as u64,
454 http_proxy: get_str("http-proxy"),
455 all_proxy: get_str("all-proxy"),
456 https_proxy: get_str("https-proxy"),
457 ftp_proxy: get_str("ftp-proxy"),
458 no_proxy: get_str("no-proxy"),
459 dht_file_path: get_str("dht-file-path"),
460 bt_max_upload_slots: {
461 let v = self.get("bt-max-upload-slots").as_usize();
462 if v > 0 { Some(v as u32) } else { None }
463 },
464 bt_optimistic_unchoke_interval: {
465 let v = self.get("bt-optimistic-unchoke-interval").as_usize();
466 if v > 0 { Some(v as u64) } else { None }
467 },
468 bt_snubbed_timeout: {
469 let v = self.get("bt-snubbed-timeout").as_usize();
470 if v > 0 { Some(v as u64) } else { None }
471 },
472 bt_prioritize_piece: self
473 .get("bt-prioritize-piece")
474 .as_str()
475 .unwrap_or("")
476 .to_string(),
477 enable_utp: self.get("enable-utp").as_bool().unwrap_or(false),
478 utp_listen_port: get_usize("utp-listen-port"),
479 header: {
480 self.get("header")
483 .as_str()
484 .unwrap_or("")
485 .split('\n')
486 .map(|s| s.trim().to_string())
487 .filter(|s| !s.is_empty())
488 .collect()
489 },
490 user_agent: get_str("user-agent"),
491 referer: get_str("referer"),
492 file_allocation: get_str("file-allocation"),
493 mmap_threshold: get_u64("mmap-threshold"),
494 secure_falloc: self.get("secure-falloc").as_bool().unwrap_or(false),
495 }
496 }
497
498 pub fn to_map(&self) -> HashMap<String, OptionValue> {
503 let mut map = self.defaults.clone();
505 for (k, v) in &self.options {
506 map.insert(k.clone(), v.clone());
507 }
508 map
509 }
510
511 pub fn default_count(&self) -> usize {
513 self.defaults.len()
514 }
515
516 pub fn is_explicitly_set(&self, key: &str) -> bool {
518 self.options.contains_key(key)
519 }
520
521 pub fn reset_to_default(&mut self, key: &str) {
525 self.options.remove(key);
526 }
527}
528
529impl Default for OptionHandler {
530 fn default() -> Self {
531 Self::new()
532 }
533}
534
535#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn test_defaults_populated() {
545 let handler = OptionHandler::new();
547 let expected_count = built_in_defaults().len();
548 assert_eq!(handler.default_count(), expected_count);
549 assert!(handler.default_count() > 0);
550
551 assert_eq!(handler.get("dir").as_str().unwrap_or(""), ".");
553 assert_eq!(handler.get("split").as_usize(), 5);
554 assert_eq!(handler.get("max-concurrent-downloads").as_usize(), 5);
555 assert_eq!(handler.get("max-connection-per-server").as_usize(), 16);
556 assert_eq!(handler.get("min-split-size").as_usize(), 1_048_576);
557 assert!(handler.get("continue").as_bool().unwrap_or(false));
558 assert!(!handler.get("quiet").as_bool().unwrap_or(false));
559 assert_eq!(handler.get("seed-ratio").as_f64().unwrap_or(0.0), 0.0);
560 assert_eq!(handler.get("rpc-listen-port").as_usize(), 6800);
561 assert_eq!(
562 handler.get("console-log-level").as_str().unwrap_or(""),
563 "notice"
564 );
565 }
566
567 #[test]
568 fn test_set_get_roundtrip() {
569 let mut handler = OptionHandler::new();
570
571 handler.set("dir", OptionValue::Str("/tmp/downloads".into()));
573 assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/tmp/downloads");
574
575 handler.set("split", OptionValue::Usize(16));
576 assert_eq!(handler.get("split").as_usize(), 16);
577
578 handler.set("seed-ratio", OptionValue::Float(2.5));
579 assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 2.5).abs() < f64::EPSILON);
580
581 handler.set("quiet", OptionValue::Bool(true));
582 assert!(handler.get("quiet").as_bool().unwrap_or(false));
583
584 handler.set(
585 "header",
586 OptionValue::List(vec!["X-Custom: foo".into(), "X-Bar: baz".into()]),
587 );
588 assert_eq!(handler.get("header").as_str_vec().len(), 2);
589
590 handler.set("split", OptionValue::Usize(32));
592 assert_eq!(handler.get("split").as_usize(), 32);
593
594 assert!(handler.get("nonexistent-key").is_none());
596 }
597
598 #[test]
599 #[allow(clippy::approx_constant)]
600 fn test_load_config_file() {
601 let mut handler = OptionHandler::new();
602
603 let config_content = r#"
605# This is a comment
606dir="/home/user/downloads"
607split=16
608max-connection-per-server=8
609quiet=true
610seed-ratio=1.5
611custom-list=['header1', 'header2', 'header3']
612bool-flag=yes
613number-key=42
614float-key=3.14
615
616# Another comment
617allow-overwrite=false
618"#;
619
620 let tmp_dir = std::env::temp_dir();
622 let config_path = tmp_dir.join(format!("aria2_test_config_{}.aria2rc", std::process::id()));
623 std::fs::write(&config_path, config_content).expect("Failed to write temp config");
624
625 let result = handler.load_config_file(&config_path);
627 assert!(
628 result.is_ok(),
629 "load_config_file should succeed: {:?}",
630 result.err()
631 );
632
633 assert_eq!(
635 handler.get("dir").as_str().unwrap_or(""),
636 "/home/user/downloads"
637 );
638 assert_eq!(handler.get("split").as_usize(), 16);
639 assert_eq!(handler.get("max-connection-per-server").as_usize(), 8);
640 assert!(handler.get("quiet").as_bool().unwrap_or(false));
641 assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 1.5).abs() < f64::EPSILON);
642 assert!(!handler.get("allow-overwrite").as_bool().unwrap_or(true));
643
644 let list_val = handler.get("custom-list");
646 assert_eq!(list_val.as_str_vec().len(), 3);
647 assert_eq!(list_val.as_str_vec()[0], "header1");
648
649 assert!(handler.get("bool-flag").as_bool().unwrap_or(false)); assert_eq!(handler.get("number-key").as_usize(), 42);
652 let float_val = handler.get("float-key").as_f64().unwrap_or(0.0);
653 assert!((float_val - 3.14).abs() < f64::EPSILON);
654
655 assert_eq!(handler.get("rpc-listen-port").as_usize(), 6800);
657
658 let _ = std::fs::remove_file(&config_path);
660 }
661
662 #[test]
663 fn test_apply_args_overrides_config() {
664 let mut handler = OptionHandler::new();
665
666 let config_content = r#"
668dir=/config/dir
669split=4
670quiet=false
671"#;
672 let tmp_dir = std::env::temp_dir();
673 let config_path = tmp_dir.join(format!(
674 "aria2_test_override_{}.aria2rc",
675 std::process::id()
676 ));
677 std::fs::write(&config_path, config_content).expect("Failed to write config");
678 handler
679 .load_config_file(&config_path)
680 .expect("Should load config");
681
682 assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/config/dir");
684 assert_eq!(handler.get("split").as_usize(), 4);
685 assert!(!handler.get("quiet").as_bool().unwrap_or(false));
686
687 let cli_args: Vec<String> = vec![
689 "--dir=/cli/dir".to_string(),
690 "--split=12".to_string(),
691 "--quiet".to_string(), "--max-connection-per-server=8".to_string(),
693 "--seed-ratio=2.0".to_string(),
694 "--no-continue".to_string(), ];
696 handler.apply_args(&cli_args);
697
698 assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/cli/dir");
700 assert_eq!(handler.get("split").as_usize(), 12);
701 assert!(handler.get("quiet").as_bool().unwrap_or(false)); assert_eq!(handler.get("max-connection-per-server").as_usize(), 8);
703 assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 2.0).abs() < f64::EPSILON);
704 assert!(!handler.get("continue").as_bool().unwrap_or(true)); let _ = std::fs::remove_file(&config_path);
708 }
709
710 #[test]
711 fn test_to_download_options() {
712 let mut handler = OptionHandler::new();
713
714 handler.set("split", OptionValue::Usize(8));
716 handler.set("max-connection-per-server", OptionValue::Usize(4));
717 handler.set("max-download-limit", OptionValue::Usize(102400));
718 handler.set("max-upload-limit", OptionValue::Usize(51200));
719 handler.set("dir", OptionValue::Str("/data".to_string()));
720 handler.set("out", OptionValue::Str("output.bin".to_string()));
721 handler.set("seed-time", OptionValue::Usize(300));
722 handler.set("seed-ratio", OptionValue::Float(2.0));
723
724 let opts = handler.to_download_options();
725
726 assert_eq!(opts.split, Some(8));
728 assert_eq!(opts.max_connection_per_server, Some(4));
729 assert_eq!(opts.max_download_limit, Some(102400));
730 assert_eq!(opts.max_upload_limit, Some(51200));
731 assert_eq!(opts.dir, Some("/data".to_string()));
732 assert_eq!(opts.out, Some("output.bin".to_string()));
733 assert_eq!(opts.seed_time, Some(300));
734 assert_eq!(opts.seed_ratio, Some(2.0));
735
736 let handler2 = OptionHandler::new();
738 let opts2 = handler2.to_download_options();
739 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");
746 assert_eq!(handler.get("split").as_usize(), 5); assert!(!handler.is_explicitly_set("split"));
748 }
749
750 #[test]
751 #[allow(clippy::approx_constant)]
752 fn test_detect_value_type_edge_cases() {
753 assert_eq!(
755 OptionHandler::detect_value_type("true"),
756 Some(OptionValue::Bool(true))
757 );
758 assert_eq!(
759 OptionHandler::detect_value_type("false"),
760 Some(OptionValue::Bool(false))
761 );
762 assert_eq!(
763 OptionHandler::detect_value_type("yes"),
764 Some(OptionValue::Bool(true))
765 );
766 assert_eq!(
767 OptionHandler::detect_value_type("no"),
768 Some(OptionValue::Bool(false))
769 );
770 assert_eq!(
771 OptionHandler::detect_value_type("42"),
772 Some(OptionValue::Usize(42))
773 );
774 assert_eq!(
775 OptionHandler::detect_value_type("-10"),
776 Some(OptionValue::Int(-10))
777 );
778 let detected = OptionHandler::detect_value_type("3.14159")
779 .unwrap()
780 .as_f64()
781 .unwrap_or(0.0);
782 assert!((detected - 3.14159).abs() < 0.001); assert_eq!(
784 OptionHandler::detect_value_type("\"quoted string\""),
785 Some(OptionValue::Str("quoted string".into()))
786 );
787 assert_eq!(
788 OptionHandler::detect_value_type("['a','b','c']"),
789 Some(OptionValue::List(vec!["a".into(), "b".into(), "c".into()]))
790 );
791 assert_eq!(
792 OptionHandler::detect_value_type(""),
793 Some(OptionValue::None)
794 );
795 assert_eq!(
796 OptionHandler::detect_value_type("plain_text"),
797 Some(OptionValue::Str("plain_text".into()))
798 );
799 }
800
801 #[test]
802 fn test_option_value_display() {
803 assert_eq!(OptionValue::Bool(true).to_string(), "true");
804 assert_eq!(OptionValue::Usize(42).to_string(), "42");
805 assert_eq!(OptionValue::Int(-10).to_string(), "-10");
806 assert_eq!(
807 format!("{:.2}", {
808 #[allow(clippy::approx_constant)]
809 OptionValue::Float(3.14).to_string().parse::<f64>().unwrap()
810 }),
811 "3.14"
812 ); assert_eq!(OptionValue::Str("hello".to_string()).to_string(), "hello");
814 assert_eq!(
815 OptionValue::List(vec!["a".into(), "b".into()]).to_string(),
816 "a,b"
817 );
818 assert_eq!(OptionValue::None.to_string(), "");
819 }
820
821 #[test]
822 fn test_to_map_includes_all() {
823 let mut handler = OptionHandler::new();
824 handler.set("custom-key", OptionValue::Str("custom-value".into()));
825
826 let map = handler.to_map();
827 assert!(map.contains_key("dir"));
829 assert!(map.contains_key("split"));
830 assert!(map.contains_key("custom-key"));
831 assert_eq!(
832 map.get("custom-key").unwrap().as_str().unwrap_or(""),
833 "custom-value"
834 );
835 assert!(map.len() >= built_in_defaults().len());
837 }
838}