use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::error::{Aria2Error, Result};
use crate::request::request_group::{DownloadStatus, RequestGroup};
pub use super::session_entry::{
SessionEntry, decode_hex, download_options_to_map, escape_uri, unescape_uri,
};
pub async fn group_to_entry(group: &RequestGroup) -> Option<SessionEntry> {
let status = group.status().await;
match status {
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error(_) => None,
_ => {
let gid = group.gid().value();
let uris = group.uris().to_vec();
if uris.is_empty() {
return None;
}
let options = download_options_to_map(group.options());
let paused = matches!(status, DownloadStatus::Paused);
let total_length = group.get_total_length_atomic();
let completed_length = group.get_completed_length();
let upload_length = group.get_uploaded_length();
let download_speed = group.get_download_speed_cached();
let status_str = match status {
DownloadStatus::Active => "active",
DownloadStatus::Waiting => "waiting",
DownloadStatus::Paused => "paused",
DownloadStatus::Complete | DownloadStatus::Removed => "complete",
DownloadStatus::Error(_) => "error",
}
.to_string();
let error_code = match &status {
DownloadStatus::Error(_) => Some(1), _ => None,
};
let bitfield = group.get_bt_bitfield().await;
let num_pieces = group.get_bt_num_pieces();
let piece_length = group.get_bt_piece_length();
let info_hash_hex = group.get_bt_info_hash_hex_async().await;
Some(SessionEntry {
gid,
uris,
options,
paused,
total_length,
completed_length,
upload_length,
download_speed,
status: status_str,
error_code,
bitfield,
num_pieces: if num_pieces > 0 {
Some(num_pieces)
} else {
None
},
piece_length: if piece_length > 0 {
Some(piece_length)
} else {
None
},
info_hash_hex,
resume_offset: if completed_length > 0 {
Some(completed_length)
} else {
None
},
})
}
}
}
pub async fn serialize_groups(groups: &[Arc<RwLock<RequestGroup>>]) -> Result<String> {
let mut output = String::new();
for group_lock in groups {
let group = group_lock.read().await;
if let Some(entry) = group_to_entry(&group).await {
output.push_str(&entry.serialize());
output.push('\n');
}
}
Ok(output)
}
pub fn deserialize(text: &str) -> Result<Vec<SessionEntry>> {
let mut entries = Vec::new();
let mut current_text = String::new();
let mut in_entry = false;
for raw_line in text.lines() {
let line = raw_line.trim_end();
if line.is_empty() || line.starts_with('#') {
if in_entry && !current_text.is_empty() {
match SessionEntry::deserialize_line(¤t_text) {
Ok(entry) if !entry.uris.is_empty() => entries.push(entry),
Ok(_) => {} Err(e) => {
tracing::warn!("Failed to deserialize entry: {}", e);
}
}
current_text.clear();
in_entry = false;
}
continue;
}
current_text.push_str(line);
current_text.push('\n');
in_entry = true;
}
if in_entry && !current_text.is_empty() {
match SessionEntry::deserialize_line(¤t_text) {
Ok(entry) if !entry.uris.is_empty() => entries.push(entry),
Ok(_) => {}
Err(e) => {
tracing::warn!("Failed to deserialize entry: {}", e);
}
}
}
Ok(entries)
}
pub async fn load_from_file(path: &Path) -> Result<Vec<SessionEntry>> {
let content = tokio::fs::read_to_string(path).await.map_err(|e| {
Aria2Error::Io(format!(
"Failed to read session file {}: {}",
path.display(),
e
))
})?;
deserialize(&content)
}
pub async fn save_to_file(path: &Path, groups: &[Arc<RwLock<RequestGroup>>]) -> Result<()> {
let content = serialize_groups(groups).await?;
let tmp_path = path.with_extension("sess.tmp");
tokio::fs::write(&tmp_path, &content).await.map_err(|e| {
Aria2Error::Io(format!(
"Failed to write session temp file {}: {}",
tmp_path.display(),
e
))
})?;
tokio::fs::rename(&tmp_path, path).await.map_err(|e| {
Aria2Error::Io(format!(
"Failed to rename session file {}: {}",
path.display(),
e
))
})
}
pub async fn save_to_file_with_entries(path: &Path, entries: &[SessionEntry]) -> Result<()> {
let mut content = String::new();
for entry in entries {
content.push_str(&entry.serialize());
content.push('\n');
}
let tmp_path = path.with_extension("sess.tmp");
tokio::fs::write(&tmp_path, &content).await.map_err(|e| {
Aria2Error::Io(format!(
"Failed to write session temp file {}: {}",
tmp_path.display(),
e
))
})?;
tokio::fs::rename(&tmp_path, path).await.map_err(|e| {
Aria2Error::Io(format!(
"Failed to rename session file {}: {}",
path.display(),
e
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[tokio::test]
async fn test_serialize_multiple_groups() {
let input = r#"http://example.com/file1.zip
GID=1
split=4
http://example.com/file2.iso
GID=2
PAUSE=true
dir=/downloads
"#;
let entries = deserialize(input).unwrap();
assert_eq!(entries.len(), 2, "Should parse 2 entries");
assert_eq!(entries[0].uris[0], "http://example.com/file1.zip");
assert_eq!(entries[1].uris[0], "http://example.com/file2.iso");
assert!(!entries[0].paused, "First entry should not be paused");
assert!(entries[1].paused, "Second entry should be paused");
}
#[test]
fn test_deserialize_mixed_content() {
let input = r#"# Session file header
# Created by aria2-rust
# First download task
http://example.com/bigfile.tar.gz
GID=abc123def456
split=8
dir=/data/downloads
TOTAL_LENGTH=104857600
COMPLETED_LENGTH=52428800
# Second download task (paused)
ftp://mirror.example.com/distro.iso
GID=789abc012def
PAUSE=true
out=distro.iso
STATUS=paused
# Third task with mirrors
http://mirror1.com/app.exe http://mirror2.com/app.exe http://mirror3.com/app.exe
GID=fedcba098765
max-connection-per-server=4
"#;
let entries = deserialize(input).unwrap();
assert_eq!(
entries.len(),
3,
"Should parse 3 entries from mixed content"
);
assert_eq!(entries[0].gid, 0xabc123def456);
assert_eq!(entries[0].options.get("split").unwrap(), "8");
assert_eq!(entries[0].total_length, 104857600);
assert_eq!(entries[0].completed_length, 52428800);
assert!(entries[1].paused);
assert_eq!(entries[1].status, "paused");
assert_eq!(entries[1].options.get("out").unwrap(), "distro.iso");
assert_eq!(
entries[2].uris.len(),
3,
"Third entry should have 3 mirror URIs"
);
assert_eq!(
entries[2].options.get("max-connection-per-server").unwrap(),
"4"
);
}
#[test]
fn test_deserialize_empty_and_whitespace_only() {
assert!(
deserialize("").unwrap().is_empty(),
"Empty string should yield no entries"
);
assert!(
deserialize("\n\n\n").unwrap().is_empty(),
"Only newlines should yield no entries"
);
assert!(
deserialize(" \n \n ").unwrap().is_empty(),
"Whitespace-only should yield no entries"
);
assert!(
deserialize("# Just a comment\n# Another comment\n")
.unwrap()
.is_empty(),
"Comments-only should yield no entries"
);
}
#[test]
fn test_deserialize_preserves_user_options() {
let input = r#"http://example.com/file.zip
GID=1
split=4
dir=/downloads
TOTAL_LENGTH=1000
"#;
let entries = deserialize(input).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].total_length, 1000);
assert_eq!(entries[0].options.get("split").unwrap(), "4");
assert_eq!(entries[0].options.get("dir").unwrap(), "/downloads");
}
#[test]
fn test_roundtrip_full_session() {
let original_entries = vec![
SessionEntry::new(
0xABCDEF01,
vec![
"http://primary.example.com/large-file.bin".to_string(),
"http://mirror1.example.com/large-file.bin".to_string(),
"http://mirror2.example.com/large-file.bin".to_string(),
],
)
.with_options({
let mut opts = HashMap::new();
opts.insert("split".to_string(), "16".to_string());
opts.insert("max-connection-per-server".to_string(), "8".to_string());
opts.insert("dir".to_string(), "/downloads".to_string());
opts.insert("out".to_string(), "large-file.bin".to_string());
opts
}),
SessionEntry::new(
0x12345678,
vec!["ftp://server.example.com/software.iso".to_string()],
)
.paused()
.with_options({
let mut opts = HashMap::new();
opts.insert("seed-time".to_string(), "3600".to_string());
opts
}),
];
let mut serialized = String::new();
for entry in &original_entries {
serialized.push_str(&entry.serialize());
serialized.push('\n');
}
let restored_entries = deserialize(&serialized).unwrap();
assert_eq!(
restored_entries.len(),
original_entries.len(),
"Entry count should match after roundtrip"
);
assert_eq!(restored_entries[0].gid, 0xABCDEF01);
assert_eq!(restored_entries[0].uris.len(), 3);
assert_eq!(
restored_entries[0].uris[0],
"http://primary.example.com/large-file.bin"
);
assert_eq!(restored_entries[0].options.get("split").unwrap(), "16");
assert!(!restored_entries[0].paused);
assert_eq!(restored_entries[1].gid, 0x12345678);
assert!(restored_entries[1].paused);
assert_eq!(
restored_entries[1].options.get("seed-time").unwrap(),
"3600"
);
}
#[test]
fn test_error_messages_are_english() {
let path = Path::new("/nonexistent/path/aria2.session");
let _ = path; }
}