aria2_core/session/session_serializer.rs
1//! Session Serializer module - Batch operations for session file I/O
2//!
3//! This module provides high-level functions for batch serialization and
4//! deserialization of multiple download sessions. It handles file I/O
5//! operations for loading and saving session data.
6//!
7//! # Overview
8//!
9//! The session serializer is responsible for:
10//! - **Loading**: Reading session files and deserializing all entries
11//! - **Saving**: Serializing RequestGroup objects to session file format
12//! - **Batch processing**: Converting between in-memory representations and file format
13//!
14//! # Architecture
15//!
16//! This module builds upon [`SessionEntry`] from the `session_entry` module:
17//! - Individual entry parsing/serialization is handled by `SessionEntry`
18//! - This module handles multi-entry files and RequestGroup conversions
19//! - File I/O operations use atomic write patterns (write tmp + rename)
20//!
21//! # File Format
22//!
23//! Session files contain one or more entries separated by blank lines:
24//! ```text
25//! uri1\turi2
26//! GID=hex_value
27//! option=value
28//!
29//! uri3
30//! GID=another_hex
31//! PAUSE=true
32//! ```
33//!
34//! # Examples
35//!
36//! ```rust,no_run
37//! use aria2_core::session::session_serializer::{load_from_file, save_to_file};
38//! use std::path::Path;
39//!
40//! #[tokio::main]
41//! async fn main() {
42//! let path = Path::new("aria2.session");
43//! let _entries = load_from_file(path).await.unwrap();
44//! }
45//! ```
46
47use std::path::Path;
48use std::sync::Arc;
49use tokio::sync::RwLock;
50
51use crate::error::{Aria2Error, Result};
52use crate::request::request_group::{DownloadStatus, RequestGroup};
53
54// Re-export core types from session_entry module
55pub use super::session_entry::{
56 SessionEntry, decode_hex, download_options_to_map, escape_uri, unescape_uri,
57};
58
59/// Converts a RequestGroup to a SessionEntry for serialization
60///
61/// Extracts relevant information from a RequestGroup (including progress,
62/// status, BT-specific fields) and creates a SessionEntry suitable for
63/// serialization to a session file.
64///
65/// # Arguments
66///
67/// * `group` - Reference to the RequestGroup to convert
68///
69/// # Returns
70///
71/// Some(SessionEntry) if the group should be serialized (active/waiting/paused),
72/// None if the group is complete/removed/error (should not persist)
73///
74/// # Note
75///
76/// This function extracts information from both synchronous fields and
77/// async methods on the RequestGroup.
78pub async fn group_to_entry(group: &RequestGroup) -> Option<SessionEntry> {
79 let status = group.status().await;
80
81 match status {
82 DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error(_) => None,
83 _ => {
84 let gid = group.gid().value();
85 let uris = group.uris().to_vec();
86
87 if uris.is_empty() {
88 return None;
89 }
90
91 let options = download_options_to_map(group.options());
92 let paused = matches!(status, DownloadStatus::Paused);
93
94 // Extract progress information using new atomic fields (lock-free)
95 let total_length = group.get_total_length_atomic();
96 let completed_length = group.get_completed_length();
97 let upload_length = group.get_uploaded_length();
98 let download_speed = group.get_download_speed_cached();
99
100 // Convert DownloadStatus to string representation
101 let status_str = match status {
102 DownloadStatus::Active => "active",
103 DownloadStatus::Waiting => "waiting",
104 DownloadStatus::Paused => "paused",
105 DownloadStatus::Complete | DownloadStatus::Removed => "complete",
106 DownloadStatus::Error(_) => "error",
107 }
108 .to_string();
109
110 // Extract error code if in error state
111 let error_code = match &status {
112 DownloadStatus::Error(_) => Some(1), // Generic error code
113 _ => None,
114 };
115
116 // Get BT bitfield if available (async operation)
117 let bitfield = group.get_bt_bitfield().await;
118
119 // Get BT metadata fields (Task 5: session persistence enhancement)
120 let num_pieces = group.get_bt_num_pieces();
121 let piece_length = group.get_bt_piece_length();
122 let info_hash_hex = group.get_bt_info_hash_hex_async().await;
123
124 Some(SessionEntry {
125 gid,
126 uris,
127 options,
128 paused,
129
130 // Progress fields (from atomic fields for performance)
131 total_length,
132 completed_length,
133 upload_length,
134 download_speed,
135 status: status_str,
136 error_code,
137
138 // BT-specific fields (from RequestGroup)
139 bitfield,
140 num_pieces: if num_pieces > 0 {
141 Some(num_pieces)
142 } else {
143 None
144 },
145 piece_length: if piece_length > 0 {
146 Some(piece_length)
147 } else {
148 None
149 },
150 info_hash_hex,
151
152 // Resume offset (use completed_length as reasonable default)
153 resume_offset: if completed_length > 0 {
154 Some(completed_length)
155 } else {
156 None
157 },
158 })
159 }
160 }
161}
162
163/// Serializes multiple RequestGroups to session file format
164///
165/// Converts each active/waiting/paused RequestGroup into a SessionEntry
166/// and serializes them all into a single string suitable for writing to
167/// a session file.
168///
169/// # Arguments
170///
171/// * `groups` - Slice of Arc<RwLock<RequestGroup>> references
172///
173/// # Returns
174///
175/// Result containing the serialized string or an error
176///
177/// # Filtering
178///
179/// Only groups with non-empty URIs and non-terminal statuses are included.
180/// Complete, removed, and error groups are skipped.
181///
182/// # Example
183///
184/// ```rust,no_run
185/// use aria2_core::session::session_serializer::serialize_groups;
186/// use std::sync::Arc;
187/// use tokio::sync::RwLock;
188///
189/// #[tokio::main]
190/// async fn main() {
191/// let groups: Vec<Arc<RwLock<aria2_core::request::request_group::RequestGroup>>> = vec![];
192/// let _content = serialize_groups(&groups).await.unwrap();
193/// }
194/// ```
195pub async fn serialize_groups(groups: &[Arc<RwLock<RequestGroup>>]) -> Result<String> {
196 let mut output = String::new();
197
198 for group_lock in groups {
199 let group = group_lock.read().await;
200 if let Some(entry) = group_to_entry(&group).await {
201 output.push_str(&entry.serialize());
202 output.push('\n');
203 }
204 }
205
206 Ok(output)
207}
208
209/// Deserializes session file text into a vector of SessionEntry objects
210///
211/// Parses the entire contents of a session file and returns all valid
212/// entries found. Handles comments (#) and blank lines.
213///
214/// # Arguments
215///
216/// * `text` - Full contents of a session file as a string
217///
218/// # Returns
219///
220/// Result containing a Vec of successfully parsed SessionEntry objects
221///
222/// # Format Details
223///
224/// Each entry consists of:
225/// 1. A URI line (one or more tab-separated URIs)
226/// 2. Zero or more property lines (space-prefixed key=value pairs)
227/// 3. Separated from next entry by blank line
228///
229/// # Error Handling
230///
231/// - Empty lines and comments are silently skipped
232/// - Invalid values are ignored (with warnings logged)
233/// - Malformed hex strings cause bitfield to be ignored
234///
235/// # Example
236///
237/// ```rust
238/// use aria2_core::session::session_serializer::deserialize;
239///
240/// let input = r#"http://example.com/file.zip
241/// GID=1
242/// split=4
243///
244/// ftp://server/big.iso
245/// GID=2
246/// PAUSE=true
247/// "#;
248///
249/// let entries = deserialize(input).unwrap();
250/// assert_eq!(entries.len(), 2);
251/// assert!(!entries[0].paused);
252/// assert!(entries[1].paused);
253/// ```
254pub fn deserialize(text: &str) -> Result<Vec<SessionEntry>> {
255 let mut entries = Vec::new();
256 let mut current_text = String::new();
257 let mut in_entry = false;
258
259 for raw_line in text.lines() {
260 let line = raw_line.trim_end();
261
262 // Skip empty lines and comments
263 if line.is_empty() || line.starts_with('#') {
264 if in_entry && !current_text.is_empty() {
265 // End of current entry
266 match SessionEntry::deserialize_line(¤t_text) {
267 Ok(entry) if !entry.uris.is_empty() => entries.push(entry),
268 Ok(_) => {} // Skip entries with no URIs
269 Err(e) => {
270 tracing::warn!("Failed to deserialize entry: {}", e);
271 }
272 }
273 current_text.clear();
274 in_entry = false;
275 }
276 continue;
277 }
278
279 // This line belongs to current entry
280 current_text.push_str(line);
281 current_text.push('\n');
282 in_entry = true;
283 }
284
285 // Don't forget the last entry if file doesn't end with blank line
286 if in_entry && !current_text.is_empty() {
287 match SessionEntry::deserialize_line(¤t_text) {
288 Ok(entry) if !entry.uris.is_empty() => entries.push(entry),
289 Ok(_) => {}
290 Err(e) => {
291 tracing::warn!("Failed to deserialize entry: {}", e);
292 }
293 }
294 }
295
296 Ok(entries)
297}
298
299/// Loads and deserializes session entries from a file
300///
301/// Reads the specified session file and parses its contents into
302/// a vector of SessionEntry objects using atomic read operations.
303///
304/// # Arguments
305///
306/// * `path` - Path to the session file to load
307///
308/// # Returns
309///
310/// Result containing a Vec of SessionEntry objects or an IO error
311///
312/// # Errors
313///
314/// Returns an error if:
315/// - The file cannot be read (permission denied, not found, etc.)
316/// - The file contains invalid UTF-8
317///
318/// # Example
319///
320/// ```rust,no_run
321/// use aria2_core::session::session_serializer::load_from_file;
322/// use std::path::Path;
323///
324/// #[tokio::main]
325/// async fn main() {
326/// let path = Path::new("aria2.session");
327/// let _entries = load_from_file(path).await.unwrap();
328/// }
329/// ```
330pub async fn load_from_file(path: &Path) -> Result<Vec<SessionEntry>> {
331 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
332 Aria2Error::Io(format!(
333 "Failed to read session file {}: {}",
334 path.display(),
335 e
336 ))
337 })?;
338
339 deserialize(&content)
340}
341
342/// Saves multiple RequestGroups to a session file using atomic write
343///
344/// Serializes all provided RequestGroups and writes them to the specified
345/// file using an atomic write pattern (write to temp file + rename).
346/// This ensures session file integrity even if the process crashes during write.
347///
348/// # Arguments
349///
350/// * `path` - Target path for the session file
351/// * `groups` - Slice of Arc<RwLock<RequestGroup>> references to serialize
352///
353/// # Returns
354///
355/// Result indicating success or an IO error
356///
357/// # Atomic Write Strategy
358///
359/// 1. Serialize all groups to memory
360/// 2. Write to `{path}.sess.tmp` temporary file
361/// 3. Rename temp file to target path (atomic on most filesystems)
362///
363/// # Errors
364///
365/// Returns an error if:
366/// - Temporary file cannot be written
367/// - Rename operation fails
368///
369/// # Example
370///
371/// ```rust,no_run
372/// use aria2_core::session::session_serializer::save_to_file;
373/// use std::path::Path;
374/// use std::sync::Arc;
375/// use tokio::sync::RwLock;
376///
377/// #[tokio::main]
378/// async fn main() {
379/// let path = Path::new("aria2.session");
380/// let groups: Vec<Arc<RwLock<aria2_core::request::request_group::RequestGroup>>> = vec![];
381/// save_to_file(path, &groups).await.unwrap();
382/// }
383/// ```
384pub async fn save_to_file(path: &Path, groups: &[Arc<RwLock<RequestGroup>>]) -> Result<()> {
385 let content = serialize_groups(groups).await?;
386 let tmp_path = path.with_extension("sess.tmp");
387
388 tokio::fs::write(&tmp_path, &content).await.map_err(|e| {
389 Aria2Error::Io(format!(
390 "Failed to write session temp file {}: {}",
391 tmp_path.display(),
392 e
393 ))
394 })?;
395
396 tokio::fs::rename(&tmp_path, path).await.map_err(|e| {
397 Aria2Error::Io(format!(
398 "Failed to rename session file {}: {}",
399 path.display(),
400 e
401 ))
402 })
403}
404
405/// Saves pre-serialized SessionEntry list directly to file (bypasses RequestGroup conversion)
406///
407/// Useful when you already have SessionEntry objects and want to save them
408/// without converting through RequestGroup. Uses atomic write pattern for safety.
409///
410/// # Arguments
411///
412/// * `path` - Target path for the session file
413/// * `entries` - Slice of SessionEntry objects to serialize and save
414///
415/// # Returns
416///
417/// Result indicating success or an IO error
418///
419/// # When to Use
420///
421/// - Testing session persistence without full RequestGroup setup
422/// - Migrating sessions from another source
423/// - Manual session manipulation tools
424///
425/// # Atomic Write Strategy
426///
427/// Same as [`save_to_file()`]: write to `.sess.tmp` then rename atomically.
428///
429/// # Example
430///
431/// ```rust,no_run
432/// use aria2_core::session::session_entry::SessionEntry;
433/// use aria2_core::session::session_serializer::save_to_file_with_entries;
434/// use std::path::Path;
435///
436/// #[tokio::main]
437/// async fn main() {
438/// let path = Path::new("custom.session");
439/// let entries = vec![
440/// SessionEntry::new(1, vec!["http://example.com/f".to_string()]),
441/// ];
442/// save_to_file_with_entries(path, &entries).await.unwrap();
443/// }
444/// ```
445pub async fn save_to_file_with_entries(path: &Path, entries: &[SessionEntry]) -> Result<()> {
446 let mut content = String::new();
447 for entry in entries {
448 content.push_str(&entry.serialize());
449 content.push('\n');
450 }
451
452 let tmp_path = path.with_extension("sess.tmp");
453
454 tokio::fs::write(&tmp_path, &content).await.map_err(|e| {
455 Aria2Error::Io(format!(
456 "Failed to write session temp file {}: {}",
457 tmp_path.display(),
458 e
459 ))
460 })?;
461
462 tokio::fs::rename(&tmp_path, path).await.map_err(|e| {
463 Aria2Error::Io(format!(
464 "Failed to rename session file {}: {}",
465 path.display(),
466 e
467 ))
468 })
469}
470
471// ==================== Unit Tests ====================
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use std::collections::HashMap;
477
478 #[tokio::test]
479 async fn test_serialize_multiple_groups() {
480 // Test that serialize_groups properly filters and serializes multiple groups
481 // This test would require mock RequestGroup objects
482 // For now, we test the deserialize function which handles multiple entries
483
484 let input = r#"http://example.com/file1.zip
485 GID=1
486 split=4
487
488http://example.com/file2.iso
489 GID=2
490 PAUSE=true
491 dir=/downloads
492"#;
493
494 let entries = deserialize(input).unwrap();
495 assert_eq!(entries.len(), 2, "Should parse 2 entries");
496 assert_eq!(entries[0].uris[0], "http://example.com/file1.zip");
497 assert_eq!(entries[1].uris[0], "http://example.com/file2.iso");
498 assert!(!entries[0].paused, "First entry should not be paused");
499 assert!(entries[1].paused, "Second entry should be paused");
500 }
501
502 #[test]
503 fn test_deserialize_mixed_content() {
504 // Test handling of mixed content: comments, blanks, valid entries
505 let input = r#"# Session file header
506# Created by aria2-rust
507
508# First download task
509http://example.com/bigfile.tar.gz
510 GID=abc123def456
511 split=8
512 dir=/data/downloads
513 TOTAL_LENGTH=104857600
514 COMPLETED_LENGTH=52428800
515
516# Second download task (paused)
517ftp://mirror.example.com/distro.iso
518 GID=789abc012def
519 PAUSE=true
520 out=distro.iso
521 STATUS=paused
522
523# Third task with mirrors
524http://mirror1.com/app.exe http://mirror2.com/app.exe http://mirror3.com/app.exe
525 GID=fedcba098765
526 max-connection-per-server=4
527
528"#;
529
530 let entries = deserialize(input).unwrap();
531 assert_eq!(
532 entries.len(),
533 3,
534 "Should parse 3 entries from mixed content"
535 );
536
537 // Verify first entry
538 assert_eq!(entries[0].gid, 0xabc123def456);
539 assert_eq!(entries[0].options.get("split").unwrap(), "8");
540 assert_eq!(entries[0].total_length, 104857600);
541 assert_eq!(entries[0].completed_length, 52428800);
542
543 // Verify second entry (paused)
544 assert!(entries[1].paused);
545 assert_eq!(entries[1].status, "paused");
546 assert_eq!(entries[1].options.get("out").unwrap(), "distro.iso");
547
548 // Verify third entry (multiple mirrors)
549 assert_eq!(
550 entries[2].uris.len(),
551 3,
552 "Third entry should have 3 mirror URIs"
553 );
554 assert_eq!(
555 entries[2].options.get("max-connection-per-server").unwrap(),
556 "4"
557 );
558 }
559
560 #[test]
561 fn test_deserialize_empty_and_whitespace_only() {
562 // Test edge cases: completely empty or whitespace-only input
563 assert!(
564 deserialize("").unwrap().is_empty(),
565 "Empty string should yield no entries"
566 );
567 assert!(
568 deserialize("\n\n\n").unwrap().is_empty(),
569 "Only newlines should yield no entries"
570 );
571 assert!(
572 deserialize(" \n \n ").unwrap().is_empty(),
573 "Whitespace-only should yield no entries"
574 );
575 assert!(
576 deserialize("# Just a comment\n# Another comment\n")
577 .unwrap()
578 .is_empty(),
579 "Comments-only should yield no entries"
580 );
581 }
582
583 #[test]
584 fn test_deserialize_preserves_user_options() {
585 // Test that user-defined options are preserved in options map
586 let input = r#"http://example.com/file.zip
587 GID=1
588 split=4
589 dir=/downloads
590 TOTAL_LENGTH=1000
591"#;
592
593 let entries = deserialize(input).unwrap();
594 assert_eq!(entries.len(), 1);
595
596 // Known field parsed correctly
597 assert_eq!(entries[0].total_length, 1000);
598
599 // User options stored in options
600 assert_eq!(entries[0].options.get("split").unwrap(), "4");
601 assert_eq!(entries[0].options.get("dir").unwrap(), "/downloads");
602 }
603
604 #[test]
605 fn test_roundtrip_full_session() {
606 // Test complete roundtrip: create entries -> serialize -> deserialize -> verify
607 let original_entries = vec![
608 SessionEntry::new(
609 0xABCDEF01,
610 vec![
611 "http://primary.example.com/large-file.bin".to_string(),
612 "http://mirror1.example.com/large-file.bin".to_string(),
613 "http://mirror2.example.com/large-file.bin".to_string(),
614 ],
615 )
616 .with_options({
617 let mut opts = HashMap::new();
618 opts.insert("split".to_string(), "16".to_string());
619 opts.insert("max-connection-per-server".to_string(), "8".to_string());
620 opts.insert("dir".to_string(), "/downloads".to_string());
621 opts.insert("out".to_string(), "large-file.bin".to_string());
622 opts
623 }),
624 SessionEntry::new(
625 0x12345678,
626 vec!["ftp://server.example.com/software.iso".to_string()],
627 )
628 .paused()
629 .with_options({
630 let mut opts = HashMap::new();
631 opts.insert("seed-time".to_string(), "3600".to_string());
632 opts
633 }),
634 ];
635
636 // Serialize all entries
637 let mut serialized = String::new();
638 for entry in &original_entries {
639 serialized.push_str(&entry.serialize());
640 serialized.push('\n');
641 }
642
643 // Deserialize back
644 let restored_entries = deserialize(&serialized).unwrap();
645
646 // Verify count matches
647 assert_eq!(
648 restored_entries.len(),
649 original_entries.len(),
650 "Entry count should match after roundtrip"
651 );
652
653 // Verify first entry details
654 assert_eq!(restored_entries[0].gid, 0xABCDEF01);
655 assert_eq!(restored_entries[0].uris.len(), 3);
656 assert_eq!(
657 restored_entries[0].uris[0],
658 "http://primary.example.com/large-file.bin"
659 );
660 assert_eq!(restored_entries[0].options.get("split").unwrap(), "16");
661 assert!(!restored_entries[0].paused);
662
663 // Verify second entry details
664 assert_eq!(restored_entries[1].gid, 0x12345678);
665 assert!(restored_entries[1].paused);
666 assert_eq!(
667 restored_entries[1].options.get("seed-time").unwrap(),
668 "3600"
669 );
670 }
671
672 #[test]
673 fn test_error_messages_are_english() {
674 // Verify that error messages are in English (not Chinese)
675 // We can't easily trigger actual errors without filesystem issues,
676 // but we can check the error message format strings exist correctly
677
678 // This test mainly documents the requirement; actual testing would
679 // require mocking filesystem errors
680 let path = Path::new("/nonexistent/path/aria2.session");
681
682 // We can't actually run this in test without blocking,
683 // but the error message strings should be English
684 // Expected: "Failed to read session file ..."
685 // Not: "读取 session 文件失败 ..."
686
687 // For now, just verify the function signature exists
688 // In production, you'd want integration tests with actual FS errors
689 let _ = path; // Suppress unused warning
690 }
691}