use std::collections::HashMap;
use crate::error::{Aria2Error, Result};
use crate::request::request_group::DownloadOptions;
pub fn escape_uri(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\t', "\\t")
.replace('\n', "\\n")
}
pub fn unescape_uri(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(&next) = chars.peek() {
match next {
't' => {
result.push('\t');
chars.next();
}
'n' => {
result.push('\n');
chars.next();
}
'\\' => {
result.push('\\');
chars.next();
}
_ => {
result.push(c);
}
}
} else {
result.push(c);
}
} else {
result.push(c);
}
}
result
}
pub fn decode_hex(hex: &str) -> Result<Vec<u8>> {
if !hex.len().is_multiple_of(2) {
return Err(Aria2Error::Io(format!(
"Hex string has odd length: {}",
hex.len()
)));
}
let mut bytes = Vec::with_capacity(hex.len() / 2);
for i in (0..hex.len()).step_by(2) {
let byte_str = &hex[i..i + 2];
let byte = u8::from_str_radix(byte_str, 16).map_err(|e| {
Aria2Error::Io(format!("Invalid hex character at position {}: {}", i, e))
})?;
bytes.push(byte);
}
Ok(bytes)
}
pub fn download_options_to_map(opts: &DownloadOptions) -> HashMap<String, String> {
let mut map = HashMap::new();
if let Some(v) = opts.split {
map.insert("split".to_string(), v.to_string());
}
if let Some(v) = opts.max_connection_per_server {
map.insert("max-connection-per-server".to_string(), v.to_string());
}
if let Some(v) = opts.max_download_limit {
map.insert("max-download-limit".to_string(), v.to_string());
}
if let Some(v) = opts.max_upload_limit {
map.insert("max-upload-limit".to_string(), v.to_string());
}
if let Some(ref v) = opts.dir {
map.insert("dir".to_string(), v.clone());
}
if let Some(ref v) = opts.out {
map.insert("out".to_string(), v.clone());
}
if let Some(v) = opts.seed_time {
map.insert("seed-time".to_string(), v.to_string());
}
if let Some(v) = opts.seed_ratio {
map.insert("seed-ratio".to_string(), v.to_string());
}
if let Some(ref v) = opts.file_allocation {
map.insert("file-allocation".to_string(), v.clone());
}
if let Some(v) = opts.mmap_threshold {
map.insert("mmap-threshold".to_string(), v.to_string());
}
if opts.secure_falloc {
map.insert("secure-falloc".to_string(), "true".to_string());
}
if let Some((ref algo, ref val)) = opts.checksum {
map.insert("checksum".to_string(), format!("{}={}", algo, val));
}
if let Some(ref v) = opts.cookie_file {
map.insert("cookie-file".to_string(), v.clone());
}
if let Some(ref v) = opts.cookies {
map.insert("cookies".to_string(), v.clone());
}
if opts.bt_force_encrypt {
map.insert("bt-force-encrypt".to_string(), "true".to_string());
}
if opts.bt_require_crypto {
map.insert("bt-require-crypto".to_string(), "true".to_string());
}
if !opts.enable_dht {
map.insert("enable-dht".to_string(), "false".to_string());
}
if let Some(v) = opts.dht_listen_port {
map.insert("dht-listen-port".to_string(), v.to_string());
}
if let Some(ref v) = opts.dht_entry_point {
map.insert("dht-entry-point".to_string(), v.join(","));
}
if !opts.enable_public_trackers {
map.insert("enable-public-trackers".to_string(), "false".to_string());
}
if !opts.bt_piece_selection_strategy.is_empty() {
map.insert(
"bt-piece-selection-strategy".to_string(),
opts.bt_piece_selection_strategy.clone(),
);
}
if opts.bt_endgame_threshold > 0 {
map.insert(
"bt-endgame-threshold".to_string(),
opts.bt_endgame_threshold.to_string(),
);
}
if let Some(v) = opts.bt_max_upload_slots {
map.insert("bt-max-upload-slots".to_string(), v.to_string());
}
if let Some(v) = opts.bt_optimistic_unchoke_interval {
map.insert("bt-optimistic-unchoke-interval".to_string(), v.to_string());
}
if let Some(v) = opts.bt_snubbed_timeout {
map.insert("bt-snubbed-timeout".to_string(), v.to_string());
}
if !opts.bt_prioritize_piece.is_empty() {
map.insert(
"bt-prioritize-piece".to_string(),
opts.bt_prioritize_piece.clone(),
);
}
if opts.enable_utp {
map.insert("enable-utp".to_string(), "true".to_string());
}
if let Some(v) = opts.utp_listen_port {
map.insert("utp-listen-port".to_string(), v.to_string());
}
if opts.max_retries > 0 {
map.insert("max-retries".to_string(), opts.max_retries.to_string());
}
if opts.retry_wait > 0 {
map.insert("retry-wait".to_string(), opts.retry_wait.to_string());
}
if let Some(ref v) = opts.dht_file_path {
map.insert("dht-file-path".to_string(), v.clone());
}
if let Some(ref v) = opts.http_proxy {
map.insert("http-proxy".to_string(), v.clone());
}
if let Some(ref v) = opts.all_proxy {
map.insert("all-proxy".to_string(), v.clone());
}
if let Some(ref v) = opts.https_proxy {
map.insert("https-proxy".to_string(), v.clone());
}
if let Some(ref v) = opts.ftp_proxy {
map.insert("ftp-proxy".to_string(), v.clone());
}
if let Some(ref v) = opts.no_proxy {
map.insert("no-proxy".to_string(), v.clone());
}
if !opts.header.is_empty() {
map.insert("header".to_string(), opts.header.join(","));
}
if let Some(ref v) = opts.user_agent {
map.insert("user-agent".to_string(), v.clone());
}
if let Some(ref v) = opts.referer {
map.insert("referer".to_string(), v.clone());
}
map
}
#[derive(Debug, Clone)]
pub struct SessionEntry {
pub gid: u64,
pub uris: Vec<String>,
pub options: HashMap<String, String>,
pub paused: bool,
pub total_length: u64,
pub completed_length: u64,
pub upload_length: u64,
pub download_speed: u64,
pub status: String,
pub error_code: Option<i32>,
pub bitfield: Option<Vec<u8>>,
pub num_pieces: Option<u32>,
pub piece_length: Option<u32>,
pub info_hash_hex: Option<String>,
pub resume_offset: Option<u64>,
}
impl SessionEntry {
pub fn new(gid: u64, uris: Vec<String>) -> Self {
SessionEntry {
gid,
uris,
options: HashMap::new(),
paused: false,
total_length: 0,
completed_length: 0,
upload_length: 0,
download_speed: 0,
status: "active".to_string(),
error_code: None,
bitfield: None,
num_pieces: None,
piece_length: None,
info_hash_hex: None,
resume_offset: None,
}
}
pub fn with_options(mut self, options: HashMap<String, String>) -> Self {
self.options = options;
self
}
pub fn paused(mut self) -> Self {
self.paused = true;
self
}
#[allow(dead_code)] fn get_option(&self, key: &str) -> Option<&str> {
self.options.get(key).map(|s| s.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_single_entry() {
let entry = SessionEntry::new(0xd270c8a2, vec!["http://example.com/file.zip".to_string()]);
let text = entry.serialize();
assert!(
text.contains("http://example.com/file.zip"),
"Should contain URI"
);
assert!(text.contains("GID=d270c8a2"), "Should contain GID");
}
#[test]
fn test_serialize_multiple_entries_roundtrip() {
let entries = vec![
SessionEntry::new(1, vec!["http://a.com/1.bin".to_string()]).with_options({
let mut m = HashMap::new();
m.insert("split".to_string(), "4".to_string());
m.insert("dir".to_string(), "/tmp".to_string());
m
}),
SessionEntry::new(
2,
vec![
"ftp://b.com/2.iso".to_string(),
"http://mirror.b.com/2.iso".to_string(),
],
)
.paused(),
];
let mut serialized = String::new();
for e in &entries {
serialized.push_str(&e.serialize());
serialized.push('\n');
}
let parts: Vec<&str> = serialized.split("\n\n").collect();
assert!(parts.len() >= 2, "Should have at least 2 entries");
let entry1 = SessionEntry::deserialize_line(parts[0]).unwrap();
assert_eq!(entry1.uris.len(), 1);
assert_eq!(entry1.uris[0], "http://a.com/1.bin");
assert_eq!(entry1.options.get("split").unwrap(), "4");
let entry2 = SessionEntry::deserialize_line(parts[1]).unwrap();
assert_eq!(entry2.uris.len(), 2);
assert!(entry2.paused);
}
#[test]
fn test_deserialize_empty_file() {
let entry = SessionEntry::deserialize_line("").unwrap();
assert!(entry.uris.is_empty());
let entry = SessionEntry::deserialize_line("\n\n\n").unwrap();
assert!(entry.uris.is_empty());
}
#[test]
fn test_deserialize_skip_comments_and_blanks() {
let input = r#"# This is a comment
# Another comment
http://example.com/file
GID=abc123
dir=/downloads
"#;
let entry = SessionEntry::deserialize_line(input).unwrap();
assert_eq!(entry.uris.len(), 1);
assert_eq!(entry.uris[0], "http://example.com/file");
}
#[test]
fn test_deserialize_options_parsing() {
let input = r#"http://example.com/file.zip
GID=1
split=4
max-connection-per-server=2
dir=C:\Users\test\Downloads
out=file.zip
"#;
let entry = SessionEntry::deserialize_line(input).unwrap();
assert_eq!(entry.options.get("split").unwrap(), "4");
assert_eq!(entry.options.get("max-connection-per-server").unwrap(), "2");
assert_eq!(
entry.options.get("dir").unwrap(),
"C:\\Users\\test\\Downloads"
);
assert_eq!(entry.options.get("out").unwrap(), "file.zip");
}
#[test]
fn test_pause_flag_serialization() {
let input = r#"http://example.com/pause.me
GID=42
PAUSE=true
"#;
let entry = SessionEntry::deserialize_line(input).unwrap();
assert!(entry.paused);
let text = entry.serialize();
assert!(text.contains("PAUSE=true"));
}
#[test]
fn test_serialize_tab_separated_uris() {
let entry = SessionEntry::new(
99,
vec![
"http://mirror1.com/f".to_string(),
"http://mirror2.com/f".to_string(),
"http://mirror3.com/f".to_string(),
],
);
let text = entry.serialize();
let uri_line = text.lines().next().unwrap();
assert_eq!(
uri_line.matches('\t').count(),
2,
"3 URIs should have 2 tab separators"
);
}
#[test]
fn test_serialize_new_fields() {
let mut entry = SessionEntry::new(1, vec!["http://example.com/file.bin".to_string()]);
entry.total_length = 1024 * 1024; entry.completed_length = 512 * 1024; entry.upload_length = 1024;
entry.download_speed = 2048;
entry.status = "active".to_string();
entry.error_code = None;
let text = entry.serialize();
assert!(
text.contains("TOTAL_LENGTH=1048576"),
"Should contain TOTAL_LENGTH"
);
assert!(
text.contains("COMPLETED_LENGTH=524288"),
"Should contain COMPLETED_LENGTH"
);
assert!(
text.contains("UPLOAD_LENGTH=1024"),
"Should contain UPLOAD_LENGTH"
);
assert!(
text.contains("DOWNLOAD_SPEED=2048"),
"Should contain DOWNLOAD_SPEED"
);
assert!(text.contains("STATUS=active"), "Should contain STATUS");
}
#[test]
fn test_deserialize_with_all_fields() {
let input = r#"http://example.com/bigfile.zip
GID=1
TOTAL_LENGTH=10485760
COMPLETED_LENGTH=5242880
UPLOAD_LENGTH=2048
DOWNLOAD_SPEED=4096
STATUS=active
ERROR_CODE=
BITFIELD=
NUM_PIECES=0
PIECE_LENGTH=0
INFO_HASH=
RESUME_OFFSET=5242880
"#;
let entry = SessionEntry::deserialize_line(input).unwrap();
assert_eq!(entry.total_length, 10485760);
assert_eq!(entry.completed_length, 5242880);
assert_eq!(entry.upload_length, 2048);
assert_eq!(entry.download_speed, 4096);
assert_eq!(entry.status, "active");
assert_eq!(entry.error_code, None);
assert_eq!(entry.resume_offset, Some(5242880));
}
#[test]
fn test_deserialize_user_options() {
let input = r#"http://example.com/file.zip
GID=1
split=4
dir=/downloads
TOTAL_LENGTH=1000
"#;
let entry = SessionEntry::deserialize_line(input).unwrap();
assert_eq!(entry.total_length, 1000);
assert_eq!(entry.options.get("split").unwrap(), "4");
assert_eq!(entry.options.get("dir").unwrap(), "/downloads");
}
#[test]
fn test_bitfield_roundtrip() {
let mut entry =
SessionEntry::new(1, vec!["http://example.com/torrent.torrent".to_string()]);
entry.bitfield = Some(vec![0xFF, 0xF0, 0x0F]);
entry.num_pieces = Some(24); entry.piece_length = Some(262144);
let text = entry.serialize();
assert!(
text.contains("BITFIELD=fff00f"),
"bitfield should be encoded as hex string"
);
let restored = SessionEntry::deserialize_line(&text).unwrap();
assert_eq!(
restored.bitfield,
Some(vec![0xFF, 0xF0, 0x0F]),
"bitfield should be restored correctly"
);
assert_eq!(restored.num_pieces, Some(24));
assert_eq!(restored.piece_length, Some(262144));
}
#[test]
fn test_empty_bitfield_serialized_as_empty() {
let entry = SessionEntry::new(1, vec!["http://example.com/file.zip".to_string()]);
let text = entry.serialize();
assert!(
text.contains("BITFIELD=\n"),
"None bitfield should be serialized as empty value"
);
let restored = SessionEntry::deserialize_line(&text).unwrap();
assert_eq!(
restored.bitfield, None,
"Empty bitfield should restore to None"
);
}
#[test]
fn test_default_session_entry_has_zero_progress() {
let entry = SessionEntry::new(99, vec!["http://test.com/f".to_string()]);
assert_eq!(entry.total_length, 0);
assert_eq!(entry.completed_length, 0);
assert_eq!(entry.upload_length, 0);
assert_eq!(entry.download_speed, 0);
assert_eq!(entry.status, "active", "Default status should be 'active'");
assert_eq!(entry.error_code, None);
assert_eq!(entry.bitfield, None);
assert_eq!(entry.num_pieces, None);
assert_eq!(entry.piece_length, None);
assert_eq!(entry.info_hash_hex, None);
assert_eq!(entry.resume_offset, None);
}
#[test]
fn test_status_field_values() {
let statuses = ["active", "waiting", "paused", "error"];
for status in statuses {
let mut entry = SessionEntry::new(1, vec!["http://example.com/f".to_string()]);
entry.status = status.to_string();
let text = entry.serialize();
assert!(
text.contains(&format!("STATUS={}", status)),
"Status '{}' should be serialized correctly",
status
);
let restored = SessionEntry::deserialize_line(&text).unwrap();
assert_eq!(
restored.status, status,
"Status '{}' should be deserialized correctly",
status
);
}
}
#[test]
fn test_resume_offset_for_http_ftp() {
let mut entry = SessionEntry::new(1, vec!["http://example.com/large-file.iso".to_string()]);
entry.total_length = 1073741824; entry.completed_length = 536870912; entry.resume_offset = Some(536870912); entry.status = "paused".to_string();
let text = entry.serialize();
assert!(
text.contains("RESUME_OFFSET=536870912"),
"resume offset should be serialized correctly"
);
let restored = SessionEntry::deserialize_line(&text).unwrap();
assert_eq!(
restored.resume_offset,
Some(536870912),
"resume offset should be restored correctly"
);
assert_eq!(restored.status, "paused");
}
#[test]
fn test_bt_specific_fields_only_when_present() {
let mut entry = SessionEntry::new(1, vec!["magnet:?xt=urn:btih:abc123".to_string()]);
let text_without_bt = entry.serialize();
let restored_without_bt = SessionEntry::deserialize_line(&text_without_bt).unwrap();
assert_eq!(restored_without_bt.bitfield, None);
assert_eq!(restored_without_bt.num_pieces, None);
assert_eq!(restored_without_bt.piece_length, None);
assert_eq!(restored_without_bt.info_hash_hex, None);
entry.bitfield = Some(vec![0xAA, 0xBB]);
entry.num_pieces = Some(16);
entry.piece_length = Some(524288);
entry.info_hash_hex = Some("abc123def456".to_string());
let text_with_bt = entry.serialize();
let restored_with_bt = SessionEntry::deserialize_line(&text_with_bt).unwrap();
assert_eq!(restored_with_bt.bitfield, Some(vec![0xAA, 0xBB]));
assert_eq!(restored_with_bt.num_pieces, Some(16));
assert_eq!(restored_with_bt.piece_length, Some(524288));
assert_eq!(
restored_with_bt.info_hash_hex,
Some("abc123def456".to_string())
);
}
#[test]
fn test_download_options_to_map_all_fields() {
let opts = DownloadOptions {
split: Some(8),
max_connection_per_server: Some(4),
max_download_limit: Some(102400),
max_upload_limit: Some(51200),
dir: Some("/downloads".to_string()),
out: Some("file.bin".to_string()),
seed_time: Some(3600),
seed_ratio: Some(2.0),
file_allocation: Some("trunc".to_string()),
mmap_threshold: Some(128 * 1024 * 1024),
secure_falloc: true,
checksum: Some(("sha256".to_string(), "abc123".to_string())),
cookie_file: Some("/tmp/cookies.txt".to_string()),
cookies: Some("key=value".to_string()),
bt_force_encrypt: true,
bt_require_crypto: true,
enable_dht: false,
dht_listen_port: Some(6881),
dht_entry_point: Some(vec!["router.bittorrent.com:6881".to_string()]),
enable_public_trackers: false,
bt_piece_selection_strategy: "sequential".to_string(),
bt_endgame_threshold: 10,
bt_max_upload_slots: Some(4),
bt_optimistic_unchoke_interval: Some(30),
bt_snubbed_timeout: Some(60),
bt_prioritize_piece: "head".to_string(),
enable_utp: true,
utp_listen_port: Some(6882),
max_retries: 5,
retry_wait: 3,
dht_file_path: Some("/tmp/dht.dat".to_string()),
http_proxy: Some("http://proxy:8080".to_string()),
all_proxy: Some("socks5://proxy:1080".to_string()),
https_proxy: Some("http://proxy:8443".to_string()),
ftp_proxy: Some("http://proxy:8021".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()),
header: vec!["X-Custom: foo".to_string(), "X-Other: bar".to_string()],
user_agent: Some("aria2-rust/1.0".to_string()),
referer: Some("http://example.com".to_string()),
};
let map = download_options_to_map(&opts);
assert_eq!(map.get("file-allocation").unwrap(), "trunc");
assert_eq!(map.get("mmap-threshold").unwrap(), "134217728");
assert_eq!(map.get("secure-falloc").unwrap(), "true");
assert_eq!(map.get("checksum").unwrap(), "sha256=abc123");
assert_eq!(map.get("cookie-file").unwrap(), "/tmp/cookies.txt");
assert_eq!(map.get("cookies").unwrap(), "key=value");
assert_eq!(map.get("bt-force-encrypt").unwrap(), "true");
assert_eq!(map.get("bt-require-crypto").unwrap(), "true");
assert_eq!(map.get("enable-dht").unwrap(), "false");
assert_eq!(map.get("dht-listen-port").unwrap(), "6881");
assert_eq!(
map.get("dht-entry-point").unwrap(),
"router.bittorrent.com:6881"
);
assert_eq!(map.get("enable-public-trackers").unwrap(), "false");
assert_eq!(
map.get("bt-piece-selection-strategy").unwrap(),
"sequential"
);
assert_eq!(map.get("bt-endgame-threshold").unwrap(), "10");
assert_eq!(map.get("bt-max-upload-slots").unwrap(), "4");
assert_eq!(map.get("bt-optimistic-unchoke-interval").unwrap(), "30");
assert_eq!(map.get("bt-snubbed-timeout").unwrap(), "60");
assert_eq!(map.get("bt-prioritize-piece").unwrap(), "head");
assert_eq!(map.get("enable-utp").unwrap(), "true");
assert_eq!(map.get("utp-listen-port").unwrap(), "6882");
assert_eq!(map.get("max-retries").unwrap(), "5");
assert_eq!(map.get("retry-wait").unwrap(), "3");
assert_eq!(map.get("dht-file-path").unwrap(), "/tmp/dht.dat");
assert_eq!(map.get("http-proxy").unwrap(), "http://proxy:8080");
assert_eq!(map.get("all-proxy").unwrap(), "socks5://proxy:1080");
assert_eq!(map.get("https-proxy").unwrap(), "http://proxy:8443");
assert_eq!(map.get("ftp-proxy").unwrap(), "http://proxy:8021");
assert_eq!(map.get("no-proxy").unwrap(), "localhost,127.0.0.1");
assert_eq!(map.get("header").unwrap(), "X-Custom: foo,X-Other: bar");
assert_eq!(map.get("user-agent").unwrap(), "aria2-rust/1.0");
assert_eq!(map.get("referer").unwrap(), "http://example.com");
}
#[test]
fn test_download_options_to_map_defaults_excluded() {
let opts = DownloadOptions::default();
let map = download_options_to_map(&opts);
assert!(!map.contains_key("secure-falloc"));
assert!(!map.contains_key("file-allocation"));
assert!(!map.contains_key("enable-dht"));
assert!(!map.contains_key("enable-public-trackers"));
}
}