Skip to main content

aria2_core/engine/
resume_data.rs

1//! Resume Data (.aria2) Serialization System
2//!
3//! Provides complete download state persistence using JSON format for cross-restart
4//! resumption. This module handles serialization/deserialization of download state
5//! including progress, URIs, status, timing, and protocol-specific information.
6//!
7//! # Architecture
8//!
9//! ```text
10//! resume_data.rs (this file)
11//!   ├── ResumeData struct - Complete download state
12//!   ├── UriState struct - Per-URI status tracking
13//!   ├── ChecksumInfo struct - Hash verification info
14//!   ├── ResumeDataExt trait - Conversion from/to RequestGroup
15//!   ├── RestoreContext - State container for session restoration
16//!   └── impl ResumeData { serialize, deserialize, save, load }
17//!
18//! Integration:
19//!   - Works alongside existing BtProgressManager (BT-specific text format)
20//!   - Uses JSON for human-readable, debuggable output
21//!   - Supports both HTTP/FTP and BitTorrent downloads
22//! ```
23
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::fs;
27use std::path::Path;
28use std::time::{SystemTime, UNIX_EPOCH};
29use tracing::{debug, info, warn};
30
31// Re-export RequestGroup for trait impl visibility
32use crate::request::request_group::{DownloadStatus, RequestGroup};
33
34/// Complete download state for persistence across process restarts
35///
36/// This structure captures all necessary information to fully restore a download
37/// session, including progress state, URI history, error context, and protocol-
38/// specific metadata.
39///
40/// # Examples
41///
42/// ```rust,ignore
43/// use aria2_core::engine::resume_data::{ResumeData, UriState};
44///
45/// let data = ResumeData {
46///     gid: "abc123".to_string(),
47///     uris: vec![
48///         UriState {
49///             uri: "http://example.com/file.zip".to_string(),
50///             tried: true,
51///             used: true,
52///             last_result: Some("ok".to_string()),
53///             speed_bytes_per_sec: Some(1024 * 1024),
54///         },
55///     ],
56///     total_length: 1024 * 1024 * 100,
57///     completed_length: 1024 * 1024 * 50,
58///     bitfield: vec![],
59///     status: "active".to_string(),
60///     error_message: None,
61///     last_download_time: 1700000000u64,
62///     created_at: 1699900000u64,
63///     output_path: Some("/downloads/file.zip".to_string()),
64///     checksum: None,
65///     bt_info_hash: None,
66///     bt_saved_metadata_path: None,
67/// };
68///
69/// let json = data.serialize().expect("Serialization failed");
70/// assert!(json.contains("abc123"));
71/// ```
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ResumeData {
74    // ==================== Identity ====================
75    /// Unique global identifier for this download task
76    pub gid: String,
77
78    // ==================== URIs ====================
79    /// All source URIs with their individual status tracking
80    pub uris: Vec<UriState>,
81
82    // ==================== Progress ====================
83    /// Total size of the download in bytes (0 if unknown)
84    pub total_length: u64,
85
86    /// Number of bytes already downloaded and verified
87    pub completed_length: u64,
88
89    /// Number of bytes uploaded (for BitTorrent seeding)
90    pub uploaded_length: u64,
91
92    /// Per-piece completion bitmap (BitTorrent only, empty for HTTP/FTP)
93    pub bitfield: Vec<u8>,
94
95    /// Total number of pieces in torrent (BitTorrent only)
96    pub num_pieces: Option<u32>,
97
98    /// Size of each piece in bytes (BitTorrent only)
99    pub piece_length: Option<u32>,
100
101    // ==================== Status ====================
102    /// Current download status: "active", "paused", "error", "complete", "waiting"
103    pub status: String,
104
105    /// Error message if status is "error"
106    pub error_message: Option<String>,
107
108    // ==================== Timing ====================
109    /// Unix timestamp (seconds) of last download activity
110    pub last_download_time: u64,
111
112    /// Unix timestamp (seconds) when this download was created
113    pub created_at: u64,
114
115    // ==================== File Info ====================
116    /// Output file path (relative or absolute)
117    pub output_path: Option<String>,
118
119    /// Checksum verification information
120    pub checksum: Option<ChecksumInfo>,
121
122    // ==================== Download Options Subset ====================
123    /// Persisted download options needed for restoration
124    pub options: HashMap<String, String>,
125
126    // ==================== Resume Offset (HTTP/FTP) ====================
127    /// File offset where HTTP/FTP download should resume
128    pub resume_offset: Option<u64>,
129
130    // ==================== BitTorrent-Specific (Optional) ====================
131    /// Torrent info hash in hex format (40 characters)
132    pub bt_info_hash: Option<String>,
133
134    /// Path to saved .torrent metadata file
135    pub bt_saved_metadata_path: Option<String>,
136}
137
138/// Per-URI state tracking for mirror management
139///
140/// Tracks which mirrors have been attempted, their success/failure history,
141/// and observed performance characteristics.
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct UriState {
144    /// Source URI string
145    pub uri: String,
146
147    /// Whether this URI has been attempted at least once
148    pub tried: bool,
149
150    /// Whether this URI is currently in use (active connection)
151    pub used: bool,
152
153    /// Last result: "ok" on success, error message on failure
154    pub last_result: Option<String>,
155
156    /// Observed download speed from this URI (bytes/second)
157    pub speed_bytes_per_sec: Option<u64>,
158}
159
160/// Checksum information for integrity verification
161///
162/// Supports multiple hash algorithms for post-download validation.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ChecksumInfo {
165    /// Hash algorithm: "sha-256", "sha-1", "md5", etc.
166    pub algorithm: String,
167
168    /// Expected hash value in hex-encoded string
169    pub expected: String,
170}
171
172impl Default for ResumeData {
173    fn default() -> Self {
174        ResumeData {
175            gid: String::new(),
176            uris: Vec::new(),
177            total_length: 0,
178            completed_length: 0,
179            uploaded_length: 0,
180            bitfield: Vec::new(),
181            num_pieces: None,
182            piece_length: None,
183            status: "waiting".to_string(),
184            error_message: None,
185            last_download_time: 0,
186            created_at: 0,
187            output_path: None,
188            checksum: None,
189            options: HashMap::new(),
190            resume_offset: None,
191            bt_info_hash: None,
192            bt_saved_metadata_path: None,
193        }
194    }
195}
196
197impl ResumeData {
198    /// Serialize ResumeData to pretty-printed JSON string
199    ///
200    /// Produces human-readable JSON with 2-space indentation for easy debugging
201    /// and manual inspection of .aria2 files.
202    ///
203    /// # Returns
204    ///
205    /// * `Ok(String)` - JSON string representation
206    /// * `Err(String)` - Serialization error with context
207    ///
208    /// # Examples
209    ///
210    /// ```rust
211    /// # use aria2_core::engine::resume_data::ResumeData;
212    /// let data = ResumeData::default();
213    /// let json = data.serialize().unwrap();
214    /// assert!(json.contains("waiting")); // default status
215    /// ```
216    pub fn serialize(&self) -> Result<String, String> {
217        serde_json::to_string_pretty(self)
218            .map_err(|e| format!("Failed to serialize resume data: {}", e))
219    }
220
221    /// Deserialize ResumeData from JSON string
222    ///
223    /// Parses a JSON string produced by [`serialize()`](ResumeData::serialize)
224    /// back into a ResumeData instance with full field restoration.
225    ///
226    /// # Arguments
227    ///
228    /// * `json_str` - JSON string to deserialize
229    ///
230    /// # Returns
231    ///
232    /// * `Ok(ResumeData)` - Deserialized data structure
233    /// * `Err(String)` - Parse error with context message
234    ///
235    /// # Errors
236    ///
237    /// Returns error if JSON is malformed, missing required fields, or contains
238    /// invalid data types.
239    ///
240    /// # Examples
241    ///
242    /// ```rust
243    /// # use aria2_core::engine::resume_data::ResumeData;
244    /// let json = r#"{"gid":"test","uris":[],"total_length":0,"completed_length":0,"uploaded_length":0,"bitfield":[],"num_pieces":null,"piece_length":null,"status":"paused","error_message":null,"last_download_time":0,"created_at":0,"output_path":null,"checksum":null,"options":{},"resume_offset":null,"bt_info_hash":null,"bt_saved_metadata_path":null}"#;
245    /// let data = ResumeData::deserialize(json).unwrap();
246    /// assert_eq!(data.gid, "test");
247    /// ```
248    pub fn deserialize(json_str: &str) -> Result<Self, String> {
249        serde_json::from_str(json_str).map_err(|e| {
250            format!(
251                "Failed to deserialize resume data: {}. JSON preview: {}",
252                e,
253                &json_str[..json_str.len().min(100)]
254            )
255        })
256    }
257
258    /// Save ResumeData to a file atomically
259    ///
260    /// Writes JSON to a temporary file first, then renames to target path.
261    /// This ensures existing files are never corrupted if write fails midway.
262    ///
263    /// # Arguments
264    ///
265    /// * `path` - Target file path (typically ending in `.aria2`)
266    ///
267    /// # Errors
268    ///
269    /// Returns error if serialization fails, file creation fails, or rename fails.
270    ///
271    /// # Examples
272    ///
273    /// ```rust,no_run
274    /// # use aria2_core::engine::resume_data::ResumeData;
275    /// # use std::path::Path;
276    /// let data = ResumeData::default();
277    /// data.save_to_file(Path::new("/tmp/download.aria2")).unwrap();
278    /// ```
279    pub fn save_to_file(&self, path: &Path) -> Result<(), String> {
280        let json = self.serialize()?;
281
282        // Use atomic write pattern: temp file -> rename
283        let tmp_path = path.with_extension("aria2.tmp");
284
285        debug!(path = %path.display(), "Saving resume data");
286
287        fs::write(&tmp_path, json).map_err(|e| {
288            format!(
289                "Failed to write temporary resume file {}: {}",
290                tmp_path.display(),
291                e
292            )
293        })?;
294
295        fs::rename(&tmp_path, path).map_err(|e| {
296            // Clean up temp file on failure
297            let _ = fs::remove_file(&tmp_path);
298            format!(
299                "Failed to atomic-rename resume file {} -> {}: {}",
300                tmp_path.display(),
301                path.display(),
302                e
303            )
304        })?;
305
306        info!(
307            gid = %self.gid,
308            completed = self.completed_length,
309            total = self.total_length,
310            path = %path.display(),
311            "Resume data saved successfully"
312        );
313
314        Ok(())
315    }
316
317    /// Load ResumeData from file, returning None if file doesn't exist
318    ///
319    /// Gracefully handles missing files (returns Ok(None)) and provides
320    /// detailed error messages for corrupt or unreadable files.
321    ///
322    /// # Arguments
323    ///
324    /// * `path` - Path to .aria2 resume file
325    ///
326    /// # Returns
327    ///
328    /// * `Ok(Some(ResumeData))` - Successfully loaded data
329    /// * `Ok(None)` - File does not exist (not an error)
330    /// * `Err(String)` - File exists but cannot be read/parsed
331    ///
332    /// # Examples
333    ///
334    /// ```rust,no_run
335    /// # use aria2_core::engine::resume_data::ResumeData;
336    /// # use std::path::Path;
337    /// match ResumeData::load_from_file(Path::new("download.aria2")) {
338    ///     Ok(Some(data)) => println!("Loaded: {} bytes done", data.completed_length),
339    ///     Ok(None) => println!("No saved state"),
340    ///     Err(e) => eprintln!("Error: {}", e),
341    /// }
342    /// ```
343    pub fn load_from_file(path: &Path) -> Result<Option<Self>, String> {
344        if !path.exists() {
345            return Ok(None);
346        }
347
348        debug!(path = %path.display(), "Loading resume data");
349
350        let json = fs::read_to_string(path)
351            .map_err(|e| format!("Failed to read resume file {}: {}", path.display(), e))?;
352
353        let data = Self::deserialize(&json)?;
354
355        info!(
356            gid = %data.gid,
357            completed = data.completed_length,
358            path = %path.display(),
359            "Resume data loaded successfully"
360        );
361
362        Ok(Some(data))
363    }
364
365    /// Calculate download completion ratio (0.0 to 1.0)
366    ///
367    /// Returns 0.0 if total_length is 0 (unknown size).
368    pub fn completion_ratio(&self) -> f64 {
369        if self.total_length == 0 {
370            return 0.0;
371        }
372        self.completed_length as f64 / self.total_length as f64
373    }
374
375    /// Check if this download is a BitTorrent transfer
376    ///
377    /// Returns true if any BT-specific fields are populated.
378    pub fn is_bit_torrent(&self) -> bool {
379        self.bt_info_hash.is_some() || !self.bitfield.is_empty()
380    }
381
382    /// Check if this download uses Metalink mirrors
383    ///
384    /// Returns true if multiple URIs are present (mirror configuration).
385    pub fn is_metalink(&self) -> bool {
386        self.uris.len() > 1
387    }
388
389    /// Generate standard .aria2 filename from GID
390    ///
391    /// Format: `{gid}.aria2`
392    pub fn get_filename(&self) -> String {
393        format!("{}.aria2", self.gid)
394    }
395
396    /// Validate resume data integrity before restoration
397    ///
398    /// Checks that critical fields are consistent and valid for restoration.
399    /// Returns Ok(()) if data is valid, Err with description otherwise.
400    pub fn validate_for_restore(&self) -> Result<(), String> {
401        // GID must not be empty
402        if self.gid.is_empty() {
403            return Err("GID must not be empty".to_string());
404        }
405
406        // Must have at least one URI
407        if self.uris.is_empty() {
408            return Err("At least one URI is required".to_string());
409        }
410
411        // Verify all URIs are non-empty strings
412        for (i, uri_state) in self.uris.iter().enumerate() {
413            if uri_state.uri.is_empty() {
414                return Err(format!("URI at index {} is empty", i));
415            }
416        }
417
418        // completed_length must not exceed total_length (unless total is unknown)
419        if self.total_length > 0 && self.completed_length > self.total_length {
420            return Err(format!(
421                "completed_length ({}) exceeds total_length ({})",
422                self.completed_length, self.total_length
423            ));
424        }
425
426        // If BT download, validate bitfield consistency
427        if self.is_bit_torrent()
428            && let Some(num_pieces) = self.num_pieces
429        {
430            let expected_bytes = (num_pieces as usize).div_ceil(8);
431            if !self.bitfield.is_empty() && self.bitfield.len() != expected_bytes {
432                warn!(
433                    expected = expected_bytes,
434                    actual = self.bitfield.len(),
435                    "Bitfield size mismatch with num_pieces"
436                );
437                // Non-fatal: just log warning
438            }
439        }
440
441        // Validate status string is known
442        match self.status.as_str() {
443            "active" | "waiting" | "paused" | "error" | "complete" => {}
444            _ => {
445                return Err(format!(
446                    "Unknown status '{}': expected one of active/waiting/paused/error/complete",
447                    self.status
448                ));
449            }
450        }
451
452        Ok(())
453    }
454
455    /// Detect download protocol type from URI patterns
456    ///
457    /// Returns "http", "ftp", "bt", "metalink", or "unknown".
458    pub fn detect_protocol(&self) -> &str {
459        if self.is_bit_torrent() {
460            "bt"
461        } else if self.uris.len() > 1 {
462            "metalink"
463        } else if let Some(first_uri_state) = self.uris.first() {
464            if first_uri_state.uri.starts_with("http://")
465                || first_uri_state.uri.starts_with("https://")
466            {
467                "http"
468            } else if first_uri_state.uri.starts_with("ftp://")
469                || first_uri_state.uri.starts_with("sftp://")
470            {
471                "ftp"
472            } else {
473                "unknown"
474            }
475        } else {
476            "unknown"
477        }
478    }
479}
480
481// =========================================================================
482// ResumeDataExt trait for converting between ResumeData and RequestGroup
483// =========================================================================
484
485/// Extension trait for converting between ResumeData and RequestGroup
486///
487/// Provides bidirectional conversion:
488/// - `from_request_group()`: Extract complete state from a live RequestGroup
489/// - `to_request_group()`: Reconstruct a RequestGroup from persisted data
490pub trait ResumeDataExt: Sized {
491    /// Create ResumeData from a RequestGroup (async, reads state)
492    ///
493    /// Extracts all persistable state from the RequestGroup including:
494    /// - Identity: GID as hex string
495    /// - URIs: Full list with initial state tracking
496    /// - Progress: total/completed/uploaded lengths, speeds
497    /// - Status: Current lifecycle status as string
498    /// - Timing: Creation and last activity timestamps
499    /// - File info: Output path from options
500    /// - Checksum: Algorithm and expected value if configured
501    /// - Options: Relevant subset for restoration
502    /// - BT-specific: bitfield, info_hash, metadata path
503    /// - HTTP-specific: resume offset for range requests
504    ///
505    /// # Arguments
506    ///
507    /// * `group` - Reference to the RequestGroup to extract state from
508    ///
509    /// # Returns
510    ///
511    /// * `Ok(ResumeData)` - Fully populated resume data
512    /// * `Err(String)` - Extraction error with context
513    fn from_request_group(
514        group: &RequestGroup,
515    ) -> impl std::future::Future<Output = Result<Self, String>> + Send;
516
517    /// Convert ResumeData back to restorable state components
518    ///
519    /// Deconstructs the ResumeData into components needed to reconstruct
520    /// a RequestGroup for session restoration.
521    ///
522    /// # Returns
523    ///
524    /// Tuple of (gid_hex, uris, options_map, restore_state) where
525    /// restore_state contains protocol-specific recovery data.
526    fn to_restore_components(
527        &self,
528    ) -> (
529        String,                  // gid_hex
530        Vec<String>,             // uris
531        HashMap<String, String>, // options
532        RestoreState,            // protocol-specific state
533    );
534}
535
536/// Protocol-specific restore state extracted from ResumeData
537///
538/// Contains the minimum information needed by each protocol handler
539/// to resume an interrupted download without re-downloading completed data.
540#[derive(Debug, Clone)]
541pub enum RestoreState {
542    /// HTTP/FTP download with range resume support
543    HttpFtp {
544        /// Byte offset to resume from (typically equals completed_length)
545        resume_offset: u64,
546        /// Total expected length (0 if unknown from server headers)
547        total_length: u64,
548        /// Bytes already written to disk
549        completed_length: u64,
550    },
551
552    /// BitTorrent download with piece bitmap
553    BitTorrent {
554        /// Piece completion bitmap
555        bitfield: Vec<u8>,
556        /// Total number of pieces
557        num_pieces: Option<u32>,
558        /// Size of each piece in bytes
559        piece_length: Option<u32>,
560        /// Torrent info hash for peer/metadata matching
561        info_hash: Option<String>,
562        /// Path to cached .torrent metadata file
563        metadata_path: Option<String>,
564    },
565
566    /// Metalink download with mirror priority
567    Metalink {
568        /// Ordered list of mirrors with priority info
569        mirrors: Vec<MirrorRestoreInfo>,
570        /// Resume offset for the selected mirror
571        resume_offset: Option<u64>,
572    },
573}
574
575/// Per-mirror restoration information for Metalink downloads
576///
577/// Captures historical performance and availability data to optimize
578/// mirror selection order after restart.
579#[derive(Debug, Clone)]
580pub struct MirrorRestoreInfo {
581    /// Mirror URI
582    pub uri: String,
583    /// Whether this mirror was previously attempted
584    pub tried: bool,
585    /// Last attempt result (None if never tried)
586    pub last_result: Option<String>,
587    /// Observed speed from this mirror (bytes/sec)
588    pub speed_bytes_per_sec: Option<u64>,
589    /// Priority score for reordering (lower = higher priority)
590    pub priority_score: u32,
591}
592
593impl ResumeDataExt for ResumeData {
594    async fn from_request_group(group: &RequestGroup) -> Result<Self, String> {
595        // Extract identity
596        let gid = group.gid().to_hex_string();
597
598        // Extract URIs with state tracking
599        let raw_uris = group.uris().to_vec();
600        let uris: Vec<UriState> = raw_uris
601            .iter()
602            .map(|uri| UriState {
603                uri: uri.clone(),
604                tried: true, // Assume all added URIs were at least considered
605                used: false, // Not actively connected at snapshot time
606                last_result: None,
607                speed_bytes_per_sec: None,
608            })
609            .collect();
610
611        // Extract progress using lock-free atomics (preferred for frequent polling)
612        let total_length = group.get_total_length_atomic();
613        let completed_length = group.get_completed_length();
614        let uploaded_length = group.get_uploaded_length();
615
616        // Extract status (async, requires lock)
617        let dl_status = group.status().await;
618        let status_str = match dl_status {
619            DownloadStatus::Active => "active",
620            DownloadStatus::Waiting => "waiting",
621            DownloadStatus::Paused => "paused",
622            DownloadStatus::Complete => "complete",
623            DownloadStatus::Removed => "removed",
624            DownloadStatus::Error(ref err) => {
625                // Include error context in the status field
626                return Err(format!(
627                    "Download in error state: {}. Error: {:?}",
628                    gid, err
629                ));
630            }
631        }
632        .to_string();
633
634        let error_message = match &dl_status {
635            DownloadStatus::Error(err) => Some(format!("{:?}", err)),
636            _ => None,
637        };
638
639        // Extract timing information
640        let created_at = SystemTime::now()
641            .duration_since(UNIX_EPOCH)
642            .unwrap_or_default()
643            .as_secs();
644        let last_download_time = created_at; // Simplified; real impl would track this
645
646        // Extract file info from options
647        let options = group.options();
648        let output_path = options.out.clone().or_else(|| {
649            // Construct path from dir + out if both exist
650            options.dir.as_ref().and_then(|dir| {
651                options.out.as_ref().map(|out| {
652                    let mut p = dir.clone();
653                    if !p.ends_with('/') && !p.ends_with('\\') {
654                        p.push(std::path::MAIN_SEPARATOR);
655                    }
656                    p.push_str(out);
657                    p
658                })
659            })
660        });
661
662        // Extract checksum if configured
663        let checksum = options
664            .checksum
665            .as_ref()
666            .map(|(algo, expected)| ChecksumInfo {
667                algorithm: algo.clone(),
668                expected: expected.clone(),
669            });
670
671        // Extract download options subset for persistence
672        let mut options_map = HashMap::new();
673        if let Some(split) = options.split {
674            options_map.insert("split".to_string(), split.to_string());
675        }
676        if let Some(mcps) = options.max_connection_per_server {
677            options_map.insert("max_connection_per-server".to_string(), mcps.to_string());
678        }
679        if let Some(ref dir) = options.dir {
680            options_map.insert("dir".to_string(), dir.clone());
681        }
682        if let Some(ref out) = options.out {
683            options_map.insert("out".to_string(), out.clone());
684        }
685        if let Some(seed_time) = options.seed_time {
686            options_map.insert("seed-time".to_string(), seed_time.to_string());
687        }
688        if let Some(seed_ratio) = options.seed_ratio {
689            options_map.insert("seed-ratio".to_string(), seed_ratio.to_string());
690        }
691
692        // Extract BT-specific fields
693        let bt_bitfield = group.get_bt_bitfield().await;
694
695        // Determine if this is a BT download from URI pattern
696        let is_bt = raw_uris
697            .iter()
698            .any(|u| u.starts_with("magnet:?") || u.ends_with(".torrent"));
699
700        let (bitfield, bt_info_hash, bt_saved_metadata_path) = if is_bt {
701            let bf = bt_bitfield.unwrap_or_default();
702            // Try to extract info hash from magnet URI
703            let info_hash = raw_uris
704                .iter()
705                .find(|u| u.starts_with("magnet:?"))
706                .and_then(|u| Self::extract_info_hash_from_magnet(u));
707            (bf, info_hash, None)
708        } else {
709            (vec![], None, None)
710        };
711
712        // Calculate resume offset for HTTP/FTP
713        let resume_offset = if completed_length > 0 && !is_bt {
714            Some(completed_length)
715        } else {
716            None
717        };
718
719        debug!(
720            gid = %gid,
721            protocol = if is_bt { "BT" } else { "HTTP/FTP" },
722            completed = completed_length,
723            total = total_length,
724            "Extracted resume data from RequestGroup"
725        );
726
727        Ok(ResumeData {
728            gid,
729            uris,
730            total_length,
731            completed_length,
732            uploaded_length,
733            bitfield,
734            num_pieces: None,   // Could be calculated from bitfield length
735            piece_length: None, // Would need to be stored in RequestGroup
736            status: status_str,
737            error_message,
738            last_download_time,
739            created_at,
740            output_path,
741            checksum,
742            options: options_map,
743            resume_offset,
744            bt_info_hash,
745            bt_saved_metadata_path,
746        })
747    }
748
749    fn to_restore_components(
750        &self,
751    ) -> (String, Vec<String>, HashMap<String, String>, RestoreState) {
752        let gid = self.gid.clone();
753        let uris: Vec<String> = self.uris.iter().map(|u| u.uri.clone()).collect();
754        let options = self.options.clone();
755
756        let restore_state = if self.is_bit_torrent() {
757            RestoreState::BitTorrent {
758                bitfield: self.bitfield.clone(),
759                num_pieces: self.num_pieces,
760                piece_length: self.piece_length,
761                info_hash: self.bt_info_hash.clone(),
762                metadata_path: self.bt_saved_metadata_path.clone(),
763            }
764        } else if self.is_metalink() && self.uris.len() > 1 {
765            // Build mirror list with priority scoring
766            let mirrors: Vec<MirrorRestoreInfo> = self
767                .uris
768                .iter()
769                .enumerate()
770                .map(|(i, u)| {
771                    // Calculate priority: working mirrors first, then by speed
772                    let mut priority = i as u32 * 10;
773                    if u.tried && u.last_result.as_deref() == Some("ok") {
774                        priority = 0; // Highest priority: working mirrors
775                    } else if !u.tried {
776                        priority += 5; // Untried mirrors get medium priority
777                    } else if u.last_result.is_some() {
778                        priority += 20; // Failed mirrors get lowest priority
779                    }
780
781                    MirrorRestoreInfo {
782                        uri: u.uri.clone(),
783                        tried: u.tried,
784                        last_result: u.last_result.clone(),
785                        speed_bytes_per_sec: u.speed_bytes_per_sec,
786                        priority_score: priority,
787                    }
788                })
789                .collect();
790
791            // Sort by priority score (ascending = higher priority first)
792            let mut sorted_mirrors = mirrors;
793            sorted_mirrors.sort_by_key(|m| m.priority_score);
794
795            RestoreState::Metalink {
796                mirrors: sorted_mirrors,
797                resume_offset: self.resume_offset,
798            }
799        } else {
800            RestoreState::HttpFtp {
801                resume_offset: self.resume_offset.unwrap_or(0),
802                total_length: self.total_length,
803                completed_length: self.completed_length,
804            }
805        };
806
807        (gid, uris, options, restore_state)
808    }
809}
810
811impl ResumeData {
812    /// Extract info hash from a magnet link
813    ///
814    /// Parses magnet URI format: `magnet:?xt=urn:btih:<hash>&dn=...`
815    /// and returns the hex-encoded info hash if present.
816    fn extract_info_hash_from_magnet(magnet_uri: &str) -> Option<String> {
817        // Look for xt=urn:btih: parameter
818        let start = magnet_uri.find("xt=urn:btih:")? + "xt=urn:btih:".len();
819        let end = magnet_uri[start..]
820            .find('&')
821            .unwrap_or(magnet_uri[start..].len());
822        let hash = &magnet_uri[start..start + end];
823
824        // Validate it looks like a hex hash (40 chars for SHA-1, 32 for base32)
825        if hash.len() >= 20 {
826            Some(hash.to_string())
827        } else {
828            None
829        }
830    }
831}
832
833// =========================================================================
834// Unit Tests
835// =========================================================================
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use crate::request::request_group::{DownloadOptions, GroupId};
841    use std::fs;
842    use std::path::PathBuf;
843
844    /// Helper to create a temporary directory for tests
845    fn create_test_dir() -> PathBuf {
846        let ts = std::time::SystemTime::now()
847            .duration_since(std::time::UNIX_EPOCH)
848            .unwrap_or_default()
849            .as_nanos()
850            % 1_000_000_000;
851        let dir = std::env::temp_dir().join(format!("resume_test_{}_{}", std::process::id(), ts));
852        let _ = fs::remove_dir_all(&dir);
853        fs::create_dir_all(&dir).expect("Failed to create test directory");
854        dir
855    }
856
857    /// Helper to create sample ResumeData with realistic values (HTTP download)
858    fn create_sample_resume_data() -> ResumeData {
859        ResumeData {
860            gid: "2089b05ecca3d829".to_string(),
861            uris: vec![
862                UriState {
863                    uri: "http://example.com/files/ubuntu-22.04-desktop-amd64.iso".to_string(),
864                    tried: true,
865                    used: true,
866                    last_result: Some("ok".to_string()),
867                    speed_bytes_per_sec: Some(5 * 1024 * 1024), // 5 MB/s
868                },
869                UriState {
870                    uri: "http://mirror.example.com/ubuntu-22.04-desktop-amd64.iso".to_string(),
871                    tried: false,
872                    used: false,
873                    last_result: None,
874                    speed_bytes_per_sec: None,
875                },
876                UriState {
877                    uri: "ftp://archive.ubuntu.com/ubuntu-22.04-desktop-amd64.iso".to_string(),
878                    tried: true,
879                    used: false,
880                    last_result: Some("Connection timeout".to_string()),
881                    speed_bytes_per_sec: None,
882                },
883            ],
884            total_length: 4705785856,     // ~4.38 GB
885            completed_length: 2352892928, // ~50% done
886            uploaded_length: 0,
887            bitfield: vec![],
888            num_pieces: None,
889            piece_length: None,
890            status: "active".to_string(),
891            error_message: None,
892            last_download_time: 1700000000,
893            created_at: 1699999000,
894            output_path: Some("/downloads/ubuntu-22.04-desktop-amd64.iso".to_string()),
895            checksum: Some(ChecksumInfo {
896                algorithm: "sha-256".to_string(),
897                expected: "b4517b7c8a...".to_string(), // truncated for brevity
898            }),
899            options: {
900                let mut m = HashMap::new();
901                m.insert("split".to_string(), "4".to_string());
902                m.insert("dir".to_string(), "/downloads".to_string());
903                m
904            },
905            resume_offset: Some(2352892928),
906            bt_info_hash: None,
907            bt_saved_metadata_path: None,
908        }
909    }
910
911    /// Helper to create sample BT-specific ResumeData
912    fn create_bt_resume_data() -> ResumeData {
913        ResumeData {
914            gid: "bt123456789abcdef".to_string(),
915            uris: vec![UriState {
916                uri: "magnet:?xt=urn:btih:abcdef1234567890abcdef1234567890abc&dn=TestTorrent"
917                    .to_string(),
918                tried: true,
919                used: true,
920                last_result: Some("ok".to_string()),
921                speed_bytes_per_sec: Some(2 * 1024 * 1024),
922            }],
923            total_length: 1073741824,    // 1 GB
924            completed_length: 536870912, // 512 MB (50%)
925            uploaded_length: 134217728,  // 128 MB uploaded
926            bitfield: vec![0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00], // 50% pieces done
927            num_pieces: Some(64),
928            piece_length: Some(16777216), // 16 MB per piece
929            status: "paused".to_string(),
930            error_message: None,
931            last_download_time: 1700000100,
932            created_at: 1699999100,
933            output_path: Some("/downloads/test.torrent".to_string()),
934            checksum: None,
935            options: {
936                let mut m = HashMap::new();
937                m.insert("seed-time".to_string(), "3600".to_string());
938                m.insert("seed-ratio".to_string(), "1.0".to_string());
939                m
940            },
941            resume_offset: None,
942            bt_info_hash: Some("abcdef1234567890abcdef1234567890abcdef12".to_string()),
943            bt_saved_metadata_path: Some("/downloads/.cache/test.torrent".to_string()),
944        }
945    }
946
947    /// Helper to create Metalink-style ResumeData with multiple mirrors
948    fn create_metalink_resume_data() -> ResumeData {
949        ResumeData {
950            gid: "ml98765fedcba4321".to_string(),
951            uris: vec![
952                UriState {
953                    uri: "http://mirror1.example.com/large-file.bin".to_string(),
954                    tried: true,
955                    used: true,
956                    last_result: Some("ok".to_string()),
957                    speed_bytes_per_sec: Some(10 * 1024 * 1024), // 10 MB/s - fastest
958                },
959                UriState {
960                    uri: "http://mirror2.example.com/large-file.bin".to_string(),
961                    tried: true,
962                    used: false,
963                    last_result: Some("ok".to_string()),
964                    speed_bytes_per_sec: Some(3 * 1024 * 1024), // 3 MB/s
965                },
966                UriState {
967                    uri: "http://mirror3.example.com/large-file.bin".to_string(),
968                    tried: true,
969                    used: false,
970                    last_result: Some("Connection refused".to_string()),
971                    speed_bytes_per_sec: None,
972                },
973                UriState {
974                    uri: "ftp://backup.example.com/large-file.bin".to_string(),
975                    tried: false,
976                    used: false,
977                    last_result: None,
978                    speed_bytes_per_sec: None,
979                },
980            ],
981            total_length: 524288000,     // 500 MB
982            completed_length: 262144000, // 250 MB (50%)
983            uploaded_length: 0,
984            bitfield: vec![],
985            num_pieces: None,
986            piece_length: None,
987            status: "active".to_string(),
988            error_message: None,
989            last_download_time: 1700000200,
990            created_at: 1699999200,
991            output_path: Some("/downloads/large-file.bin".to_string()),
992            checksum: Some(ChecksumInfo {
993                algorithm: "sha-1".to_string(),
994                expected: "a1b2c3d4e5f6...".to_string(),
995            }),
996            options: {
997                let mut m = HashMap::new();
998                m.insert("split".to_string(), "4".to_string());
999                m.insert("max-connection-per-server".to_string(), "2".to_string());
1000                m
1001            },
1002            resume_offset: Some(262144000),
1003            bt_info_hash: None,
1004            bt_saved_metadata_path: None,
1005        }
1006    }
1007
1008    // =====================================================================
1009    // Test Group 1: HTTP Save -> Restore Round-trip (5+ tests)
1010    // =====================================================================
1011
1012    #[test]
1013    fn test_http_serialize_deserialize_roundtrip() {
1014        let original = create_sample_resume_data();
1015
1016        let json = original.serialize().expect("HTTP serialization failed");
1017        let restored = ResumeData::deserialize(&json).expect("HTTP deserialization failed");
1018
1019        // Verify core HTTP fields survive roundtrip
1020        assert_eq!(restored.gid, original.gid, "GID mismatch");
1021        assert_eq!(
1022            restored.total_length, original.total_length,
1023            "Total length mismatch"
1024        );
1025        assert_eq!(
1026            restored.completed_length, original.completed_length,
1027            "Completed length mismatch"
1028        );
1029        assert_eq!(
1030            restored.uploaded_length, original.uploaded_length,
1031            "Upload length mismatch"
1032        );
1033        assert_eq!(restored.status, original.status, "Status mismatch");
1034        assert_eq!(
1035            restored.resume_offset, original.resume_offset,
1036            "Resume offset mismatch"
1037        );
1038        assert_eq!(
1039            restored.output_path, original.output_path,
1040            "Output path mismatch"
1041        );
1042        assert_eq!(restored.options, original.options, "Options map mismatch");
1043
1044        // Verify checksum preserved
1045        assert_eq!(
1046            restored
1047                .checksum
1048                .as_ref()
1049                .map(|c| (&c.algorithm, &c.expected)),
1050            original
1051                .checksum
1052                .as_ref()
1053                .map(|c| (&c.algorithm, &c.expected)),
1054            "Checksum mismatch"
1055        );
1056    }
1057
1058    #[test]
1059    fn test_http_resume_offset_preserved() {
1060        let data = create_sample_resume_data();
1061        assert_eq!(data.resume_offset, Some(2352892928));
1062
1063        let json = data.serialize().unwrap();
1064        let restored = ResumeData::deserialize(&json).unwrap();
1065
1066        assert_eq!(
1067            restored.resume_offset,
1068            Some(2352892928),
1069            "HTTP resume offset must survive roundtrip"
1070        );
1071
1072        // Verify resume offset equals completed_length for normal HTTP downloads
1073        assert_eq!(
1074            restored.resume_offset,
1075            Some(restored.completed_length),
1076            "Resume offset should equal completed_length for HTTP"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_http_single_uri_roundtrip() {
1082        let single = ResumeData {
1083            gid: "http-single-test".to_string(),
1084            uris: vec![UriState {
1085                uri: "http://example.com/single-file.dat".to_string(),
1086                tried: true,
1087                used: true,
1088                last_result: Some("ok".to_string()),
1089                speed_bytes_per_sec: Some(1048576),
1090            }],
1091            total_length: 10485760,
1092            completed_length: 5242880,
1093            ..Default::default()
1094        };
1095
1096        let json = single.serialize().unwrap();
1097        let restored = ResumeData::deserialize(&json).unwrap();
1098
1099        assert_eq!(restored.uris.len(), 1);
1100        assert_eq!(restored.uris[0].uri, "http://example.com/single-file.dat");
1101        assert!(
1102            !restored.is_metalink(),
1103            "Single URI should not be detected as metalink"
1104        );
1105        assert_eq!(restored.detect_protocol(), "http");
1106    }
1107
1108    #[test]
1109    fn test_http_zero_completed_roundtrip() {
1110        let zero_progress = ResumeData {
1111            gid: "http-zero-progress".to_string(),
1112            uris: vec![UriState {
1113                uri: "http://example.com/not-started.zip".to_string(),
1114                tried: false,
1115                used: false,
1116                last_result: None,
1117                speed_bytes_per_sec: None,
1118            }],
1119            total_length: 1000000,
1120            completed_length: 0,
1121            resume_offset: None,
1122            ..Default::default()
1123        };
1124
1125        let json = zero_progress.serialize().unwrap();
1126        let restored = ResumeData::deserialize(&json).unwrap();
1127
1128        assert_eq!(restored.completed_length, 0);
1129        assert_eq!(
1130            restored.resume_offset, None,
1131            "Zero progress should have no resume offset"
1132        );
1133        assert!((restored.completion_ratio() - 0.0).abs() < f64::EPSILON);
1134    }
1135
1136    #[test]
1137    fn test_http_unknown_total_size_roundtrip() {
1138        let unknown_size = ResumeData {
1139            gid: "http-unknown-size".to_string(),
1140            uris: vec![UriState {
1141                uri: "http://streaming.example.com/live.m3u8".to_string(),
1142                tried: true,
1143                used: true,
1144                last_result: Some("ok".to_string()),
1145                speed_bytes_per_sec: Some(500000),
1146            }],
1147            total_length: 0, // Unknown size (streaming)
1148            completed_length: 999999,
1149            ..Default::default()
1150        };
1151
1152        let json = unknown_size.serialize().unwrap();
1153        let restored = ResumeData::deserialize(&json).unwrap();
1154
1155        assert_eq!(restored.total_length, 0);
1156        assert_eq!(
1157            restored.completion_ratio(),
1158            0.0,
1159            "Unknown size should yield 0% ratio"
1160        );
1161    }
1162
1163    // =====================================================================
1164    // Test Group 2: BT Save -> Restore Round-trip with bitfield (5+ tests)
1165    // =====================================================================
1166
1167    #[test]
1168    fn test_bt_serialize_deserialize_roundtrip() {
1169        let original = create_bt_resume_data();
1170
1171        let json = original.serialize().expect("BT serialization failed");
1172        let restored = ResumeData::deserialize(&json).expect("BT deserialization failed");
1173
1174        // Verify BT-specific fields survive roundtrip
1175        assert_eq!(restored.gid, original.gid, "BT GID mismatch");
1176        assert_eq!(restored.bitfield, original.bitfield, "Bitfield mismatch");
1177        assert_eq!(
1178            restored.num_pieces, original.num_pieces,
1179            "Num pieces mismatch"
1180        );
1181        assert_eq!(
1182            restored.piece_length, original.piece_length,
1183            "Piece length mismatch"
1184        );
1185        assert_eq!(
1186            restored.bt_info_hash, original.bt_info_hash,
1187            "BT info hash mismatch"
1188        );
1189        assert_eq!(
1190            restored.bt_saved_metadata_path, original.bt_saved_metadata_path,
1191            "BT metadata path mismatch"
1192        );
1193        assert_eq!(
1194            restored.uploaded_length, original.uploaded_length,
1195            "Upload length mismatch"
1196        );
1197
1198        // Verify BT detection works
1199        assert!(
1200            restored.is_bit_torrent(),
1201            "Should be detected as BT download"
1202        );
1203        assert_eq!(restored.detect_protocol(), "bt");
1204    }
1205
1206    #[test]
1207    fn test_bt_bitfield_exact_preservation() {
1208        let bt = create_bt_resume_data();
1209        let expected_bitfield = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00];
1210
1211        assert_eq!(
1212            bt.bitfield, expected_bitfield,
1213            "Original bitfield should match"
1214        );
1215
1216        let json = bt.serialize().unwrap();
1217        let restored = ResumeData::deserialize(&json).unwrap();
1218
1219        assert_eq!(
1220            restored.bitfield, expected_bitfield,
1221            "Bitfield must be exactly preserved after roundtrip"
1222        );
1223        assert_eq!(
1224            restored.bitfield.len(),
1225            8,
1226            "Bitfield length must be preserved"
1227        );
1228    }
1229
1230    #[test]
1231    fn test_bt_piece_metadata_roundtrip() {
1232        let bt = create_bt_resume_data();
1233
1234        let json = bt.serialize().unwrap();
1235        let restored = ResumeData::deserialize(&json).unwrap();
1236
1237        assert_eq!(
1238            restored.num_pieces,
1239            Some(64),
1240            "Num pieces (64) must survive roundtrip"
1241        );
1242        assert_eq!(
1243            restored.piece_length,
1244            Some(16777216),
1245            "Piece length (16MB) must survive roundtrip"
1246        );
1247
1248        // Verify consistency: num_pieces * piece_length should approximate total_length
1249        if let (Some(np), Some(pl)) = (restored.num_pieces, restored.piece_length) {
1250            let calc_total = (np as u64) * (pl as u64);
1251            assert!(
1252                calc_total >= restored.total_length,
1253                "Calculated total ({}) should be >= reported total ({})",
1254                calc_total,
1255                restored.total_length
1256            );
1257        }
1258    }
1259
1260    #[test]
1261    fn test_bt_upload_length_tracking() {
1262        let bt = create_bt_resume_data();
1263        assert_eq!(bt.uploaded_length, 134217728); // 128 MB uploaded
1264
1265        let json = bt.serialize().unwrap();
1266        let restored = ResumeData::deserialize(&json).unwrap();
1267
1268        assert_eq!(
1269            restored.uploaded_length, 134217728,
1270            "Uploaded length must be tracked separately for BT seeding"
1271        );
1272    }
1273
1274    #[test]
1275    fn test_bt_no_resume_offset() {
1276        let bt = create_bt_resume_data();
1277
1278        // BT downloads should NOT use resume_offset (they use bitfield instead)
1279        assert_eq!(
1280            bt.resume_offset, None,
1281            "BT downloads should have no HTTP-style resume offset"
1282        );
1283
1284        let json = bt.serialize().unwrap();
1285        let restored = ResumeData::deserialize(&json).unwrap();
1286
1287        assert_eq!(restored.resume_offset, None);
1288    }
1289
1290    #[test]
1291    fn test_bt_magnet_info_hash_extraction() {
1292        // Test that we can extract info hash from magnet links
1293        let magnet = "magnet:?xt=urn:btih:abcdef1234567890abcdef1234567890abc&dn=TestFile";
1294        let hash = ResumeData::extract_info_hash_from_magnet(magnet);
1295
1296        assert!(hash.is_some(), "Should extract info hash from magnet link");
1297        assert_eq!(
1298            hash.unwrap(),
1299            "abcdef1234567890abcdef1234567890abc",
1300            "Extracted hash should match"
1301        );
1302    }
1303
1304    #[test]
1305    fn test_bt_empty_bitfield_is_not_bt() {
1306        // A download with empty bitfield and no info_hash should NOT be detected as BT
1307        let not_bt = ResumeData {
1308            gid: "not-bt-test".to_string(),
1309            uris: vec![UriState {
1310                uri: "http://example.com/file.zip".to_string(),
1311                ..Default::default()
1312            }],
1313            bitfield: vec![],
1314            bt_info_hash: None,
1315            ..Default::default()
1316        };
1317
1318        assert!(
1319            !not_bt.is_bit_torrent(),
1320            "Empty bitfield + no info_hash should not be detected as BT"
1321        );
1322    }
1323
1324    // =====================================================================
1325    // Test Group 3: Metalink Save -> Restore Round-trip (5+ tests)
1326    // =====================================================================
1327
1328    #[test]
1329    fn test_metalink_serialize_deserialize_roundtrip() {
1330        let original = create_metalink_resume_data();
1331
1332        let json = original.serialize().expect("Metalink serialization failed");
1333        let restored = ResumeData::deserialize(&json).expect("Metalink deserialization failed");
1334
1335        // Verify all mirrors preserved
1336        assert_eq!(
1337            restored.uris.len(),
1338            4,
1339            "All 4 mirrors must survive roundtrip"
1340        );
1341
1342        // Verify Metalink detection
1343        assert!(
1344            restored.is_metalink(),
1345            "Multiple URIs should be detected as metalink"
1346        );
1347        assert_eq!(restored.detect_protocol(), "metalink");
1348
1349        // Verify per-mirror state preservation
1350        for (orig_u, rest_u) in original.uris.iter().zip(restored.uris.iter()) {
1351            assert_eq!(orig_u.uri, rest_u.uri, "Mirror URI mismatch");
1352            assert_eq!(orig_u.tried, rest_u.tried, "Tried flag mismatch");
1353            assert_eq!(orig_u.used, rest_u.used, "Used flag mismatch");
1354            assert_eq!(orig_u.last_result, rest_u.last_result, "Result mismatch");
1355            assert_eq!(
1356                orig_u.speed_bytes_per_sec, rest_u.speed_bytes_per_sec,
1357                "Speed mismatch"
1358            );
1359        }
1360    }
1361
1362    #[test]
1363    fn test_metalink_mirror_priority_ordering() {
1364        let ml = create_metalink_resume_data();
1365
1366        // Convert to restore components and check mirror ordering
1367        let (_gid, _uris, _options, restore_state) = ml.to_restore_components();
1368
1369        match restore_state {
1370            RestoreState::Metalink { mirrors, .. } => {
1371                // First mirror should be highest priority (working, fastest)
1372                assert_eq!(
1373                    mirrors[0].uri, "http://mirror1.example.com/large-file.bin",
1374                    "Fastest working mirror should be first"
1375                );
1376                assert_eq!(
1377                    mirrors[0].priority_score, 0,
1378                    "Best mirror should have score 0"
1379                );
1380
1381                // Failed mirror should have lower priority
1382                let failed_mirror = mirrors
1383                    .iter()
1384                    .find(|m| m.uri.contains("mirror3"))
1385                    .expect("Failed mirror should be present");
1386                assert!(
1387                    failed_mirror.priority_score > 10,
1388                    "Failed mirror should have low priority (high score)"
1389                );
1390
1391                // Untried backup mirror should be in middle (between working and failed)
1392                let untried = mirrors
1393                    .iter()
1394                    .find(|m| m.uri.contains("backup"))
1395                    .expect("Untried mirror should be present");
1396                assert!(
1397                    untried.priority_score < failed_mirror.priority_score,
1398                    "Untried mirror (score={}) should have better priority than failed mirror (score={})",
1399                    untried.priority_score,
1400                    failed_mirror.priority_score
1401                );
1402                assert!(
1403                    untried.priority_score > mirrors[0].priority_score,
1404                    "Untried mirror (score={}) should have lower priority than best working mirror (score={})",
1405                    untried.priority_score,
1406                    mirrors[0].priority_score
1407                );
1408            }
1409            _ => panic!("Expected Metalink restore state"),
1410        }
1411    }
1412
1413    #[test]
1414    fn test_metalink_speed_based_ranking() {
1415        let ml = create_metalink_resume_data();
1416        let (_, _, _, restore_state) = ml.to_restore_components();
1417
1418        match restore_state {
1419            RestoreState::Metalink { mirrors, .. } => {
1420                // Mirrors should be sorted by priority (ascending)
1421                for window in mirrors.windows(2) {
1422                    assert!(
1423                        window[0].priority_score <= window[1].priority_score,
1424                        "Mirrors should be sorted by priority ascending: {} (score={}) <= {} (score={})",
1425                        window[0].uri,
1426                        window[0].priority_score,
1427                        window[1].uri,
1428                        window[1].priority_score
1429                    );
1430                }
1431            }
1432            _ => panic!("Expected Metalink restore state"),
1433        }
1434    }
1435
1436    #[test]
1437    fn test_metalink_checksum_preserved() {
1438        let ml = create_metalink_resume_data();
1439        assert!(ml.checksum.is_some());
1440
1441        let json = ml.serialize().unwrap();
1442        let restored = ResumeData::deserialize(&json).unwrap();
1443
1444        assert_eq!(
1445            restored.checksum.as_ref().map(|c| c.algorithm.as_str()),
1446            Some("sha-1"),
1447            "Metalink checksum algorithm should be preserved"
1448        );
1449    }
1450
1451    #[test]
1452    fn test_metalink_resume_offset_in_restore_state() {
1453        let ml = create_metalink_resume_data();
1454        assert_eq!(ml.resume_offset, Some(262144000));
1455
1456        let (_, _, _, restore_state) = ml.to_restore_components();
1457
1458        match restore_state {
1459            RestoreState::Metalink { resume_offset, .. } => {
1460                assert_eq!(
1461                    resume_offset,
1462                    Some(262144000),
1463                    "Metalink resume offset must be in restore state"
1464                );
1465            }
1466            _ => panic!("Expected Metalink restore state"),
1467        }
1468    }
1469
1470    // =====================================================================
1471    // Test Group 4: Edge cases and error handling
1472    // =====================================================================
1473
1474    #[test]
1475    fn test_edge_case_empty_uris() {
1476        let empty_uris = ResumeData {
1477            gid: "empty-uri-test".to_string(),
1478            uris: vec![],
1479            ..Default::default()
1480        };
1481
1482        let json = empty_uris.serialize().unwrap();
1483        let restored = ResumeData::deserialize(&json).unwrap();
1484
1485        assert!(
1486            restored.uris.is_empty(),
1487            "Empty URI list should be preserved"
1488        );
1489        assert!(!restored.is_metalink(), "Empty URIs should not be metalink");
1490        assert_eq!(restored.detect_protocol(), "unknown");
1491    }
1492
1493    #[test]
1494    fn test_edge_case_zero_length_file() {
1495        let zero_len = ResumeData {
1496            gid: "zero-len-test".to_string(),
1497            uris: vec![UriState {
1498                uri: "http://example.com/empty-file".to_string(),
1499                ..Default::default()
1500            }],
1501            total_length: 0,
1502            completed_length: 0,
1503            status: "complete".to_string(),
1504            ..Default::default()
1505        };
1506
1507        let json = zero_len.serialize().unwrap();
1508        let restored = ResumeData::deserialize(&json).unwrap();
1509
1510        assert_eq!(restored.total_length, 0);
1511        assert_eq!(restored.completed_length, 0);
1512        assert_eq!(restored.status, "complete");
1513        assert_eq!(restored.completion_ratio(), 0.0);
1514    }
1515
1516    #[test]
1517    fn test_validate_good_data_passes() {
1518        let good = create_sample_resume_data();
1519        let result = good.validate_for_restore();
1520        assert!(
1521            result.is_ok(),
1522            "Valid data should pass validation: {:?}",
1523            result.err()
1524        );
1525    }
1526
1527    #[test]
1528    fn test_validate_empty_gid_fails() {
1529        let bad = ResumeData {
1530            gid: String::new(),
1531            uris: vec![UriState {
1532                uri: "http://example.com/f".to_string(),
1533                ..Default::default()
1534            }],
1535            ..Default::default()
1536        };
1537        let result = bad.validate_for_restore();
1538        assert!(result.is_err(), "Empty GID should fail validation");
1539        assert!(
1540            result.unwrap_err().contains("GID"),
1541            "Error should mention GID"
1542        );
1543    }
1544
1545    #[test]
1546    fn test_validate_no_uris_fails() {
1547        let bad = ResumeData {
1548            gid: "has-gid-but-no-uris".to_string(),
1549            uris: vec![],
1550            ..Default::default()
1551        };
1552        let result = bad.validate_for_restore();
1553        assert!(result.is_err(), "No URIs should fail validation");
1554        assert!(
1555            result.unwrap_err().contains("URI"),
1556            "Error should mention URI"
1557        );
1558    }
1559
1560    #[test]
1561    fn test_validate_completed_exceeds_total_fails() {
1562        let bad = ResumeData {
1563            gid: "overflow-test".to_string(),
1564            uris: vec![UriState {
1565                uri: "http://example.com/f".to_string(),
1566                ..Default::default()
1567            }],
1568            total_length: 1000,
1569            completed_length: 2000, // Exceeds total!
1570            ..Default::default()
1571        };
1572        let result = bad.validate_for_restore();
1573        assert!(result.is_err(), "Completed > total should fail validation");
1574        assert!(
1575            result.unwrap_err().contains("exceeds"),
1576            "Error should mention overflow"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_validate_invalid_status_fails() {
1582        let bad = ResumeData {
1583            gid: "bad-status-test".to_string(),
1584            uris: vec![UriState {
1585                uri: "http://example.com/f".to_string(),
1586                ..Default::default()
1587            }],
1588            status: "invalid_status_xyz".to_string(),
1589            ..Default::default()
1590        };
1591        let result = bad.validate_for_restore();
1592        assert!(result.is_err(), "Invalid status should fail validation");
1593        assert!(
1594            result.unwrap_err().contains("Unknown status"),
1595            "Error should mention unknown status"
1596        );
1597    }
1598
1599    #[test]
1600    fn test_detect_protocol_variants() {
1601        // HTTP
1602        let http = ResumeData {
1603            gid: "1".to_string(),
1604            uris: vec![UriState {
1605                uri: "https://secure.example.com/f".to_string(),
1606                ..Default::default()
1607            }],
1608            ..Default::default()
1609        };
1610        assert_eq!(http.detect_protocol(), "http");
1611
1612        // FTP
1613        let ftp = ResumeData {
1614            gid: "2".to_string(),
1615            uris: vec![UriState {
1616                uri: "sftp://server/file".to_string(),
1617                ..Default::default()
1618            }],
1619            ..Default::default()
1620        };
1621        assert_eq!(ftp.detect_protocol(), "ftp");
1622
1623        // BT via info_hash
1624        let bt = ResumeData {
1625            gid: "3".to_string(),
1626            uris: vec![UriState {
1627                uri: "http://tracker.example.com/f".to_string(),
1628                ..Default::default()
1629            }],
1630            bt_info_hash: Some("abcd1234".to_string()),
1631            ..Default::default()
1632        };
1633        assert_eq!(bt.detect_protocol(), "bt");
1634
1635        // Unknown
1636        let unknown = ResumeData {
1637            gid: "4".to_string(),
1638            uris: vec![],
1639            ..Default::default()
1640        };
1641        assert_eq!(unknown.detect_protocol(), "unknown");
1642    }
1643
1644    // =====================================================================
1645    // Test Group 5: Existing tests
1646    // =====================================================================
1647
1648    #[test]
1649    fn test_resume_data_serialize_deserialize_full_roundtrip() {
1650        let original = create_sample_resume_data();
1651
1652        // Serialize to JSON
1653        let json = original.serialize().expect("Serialization failed");
1654
1655        // Verify JSON contains key fields
1656        assert!(json.contains("2089b05ecca3d829"), "JSON should contain GID");
1657        assert!(json.contains("active"), "JSON should contain status");
1658        assert!(
1659            json.contains("4705785856"),
1660            "JSON should contain total_length"
1661        );
1662        assert!(
1663            json.contains("ubuntu-22.04-desktop-amd64.iso"),
1664            "JSON should contain filename"
1665        );
1666
1667        // Deserialize back
1668        let restored = ResumeData::deserialize(&json).expect("Deserialization failed");
1669
1670        // Verify all fields match exactly
1671        assert_eq!(restored.gid, original.gid, "GID mismatch");
1672        assert_eq!(
1673            restored.uris.len(),
1674            original.uris.len(),
1675            "URI count mismatch"
1676        );
1677        assert_eq!(
1678            restored.total_length, original.total_length,
1679            "Total length mismatch"
1680        );
1681        assert_eq!(
1682            restored.completed_length, original.completed_length,
1683            "Completed length mismatch"
1684        );
1685        assert_eq!(restored.status, original.status, "Status mismatch");
1686        assert_eq!(
1687            restored.error_message, original.error_message,
1688            "Error message mismatch"
1689        );
1690        assert_eq!(
1691            restored.last_download_time, original.last_download_time,
1692            "Timestamp mismatch"
1693        );
1694        assert_eq!(
1695            restored.created_at, original.created_at,
1696            "Created at mismatch"
1697        );
1698        assert_eq!(
1699            restored.output_path, original.output_path,
1700            "Output path mismatch"
1701        );
1702
1703        println!("Full roundtrip test passed. JSON:\n{}", json);
1704    }
1705
1706    #[test]
1707    fn test_resume_data_save_load_file() {
1708        let test_dir = create_test_dir();
1709        let file_path = test_dir.join("test_download.aria2");
1710        let original = create_sample_resume_data();
1711
1712        // Save to file
1713        original.save_to_file(&file_path).expect("Save failed");
1714
1715        // Verify file exists
1716        assert!(file_path.exists(), "Resume file should exist after save");
1717
1718        // Load from file
1719        let loaded = ResumeData::load_from_file(&file_path)
1720            .expect("Load failed")
1721            .expect("Should have returned Some(data)");
1722
1723        // Verify data integrity
1724        assert_eq!(
1725            loaded.gid, original.gid,
1726            "GID mismatch after file roundtrip"
1727        );
1728        assert_eq!(loaded.uris.len(), original.uris.len(), "URI count mismatch");
1729        assert_eq!(
1730            loaded.total_length, original.total_length,
1731            "Total length mismatch"
1732        );
1733        assert_eq!(
1734            loaded.completed_length, original.completed_length,
1735            "Completed length mismatch"
1736        );
1737        assert_eq!(loaded.status, original.status, "Status mismatch");
1738        assert_eq!(
1739            loaded.output_path, original.output_path,
1740            "Output path mismatch"
1741        );
1742
1743        // Verify no temp file left behind
1744        let tmp_path = file_path.with_extension("aria2.tmp");
1745        assert!(!tmp_path.exists(), "No temporary file should remain");
1746
1747        // Clean up
1748        let _ = fs::remove_dir_all(&test_dir);
1749
1750        println!("File save/load test passed");
1751    }
1752
1753    #[test]
1754    fn test_resume_data_missing_file_returns_none() {
1755        let test_dir = create_test_dir();
1756        let nonexistent_path = test_dir.join("nonexistent.aria2");
1757
1758        // Should return Ok(None), not error
1759        let result = ResumeData::load_from_file(&nonexistent_path)
1760            .expect("Missing file should not return error");
1761
1762        assert!(result.is_none(), "Should return None for non-existent file");
1763
1764        // Clean up
1765        let _ = fs::remove_dir_all(&test_dir);
1766
1767        println!("Missing file test passed");
1768    }
1769
1770    #[test]
1771    fn test_resume_data_corrupt_json_returns_error() {
1772        let test_dir = create_test_dir();
1773        let file_path = test_dir.join("corrupt.aria2");
1774
1775        // Test case 1: Completely invalid content
1776        fs::write(&file_path, "This is not JSON at all! @#$%^&*()")
1777            .expect("Failed to write corrupt file");
1778        let result = ResumeData::load_from_file(&file_path);
1779        assert!(result.is_err(), "Corrupt JSON should return error");
1780        assert!(
1781            result.unwrap_err().contains("Failed to deserialize"),
1782            "Error should mention deserialization"
1783        );
1784
1785        // Test case 2: Truncated JSON
1786        fs::write(&file_path, "{\"gid\":\"test\",\"uris\":[]")
1787            .expect("Failed to write truncated JSON");
1788        let result = ResumeData::load_from_file(&file_path);
1789        assert!(result.is_err(), "Truncated JSON should return error");
1790
1791        // Test case 3: Valid JSON but wrong structure (missing required fields)
1792        fs::write(&file_path, "{\"wrong_field\": 123}").expect("Failed to write invalid structure");
1793        let result = ResumeData::load_from_file(&file_path);
1794        assert!(result.is_err(), "Invalid structure should return error");
1795
1796        // Test case 4: Empty file
1797        fs::write(&file_path, "").expect("Failed to write empty file");
1798        let result = ResumeData::load_from_file(&file_path);
1799        assert!(result.is_err(), "Empty file should return error");
1800
1801        // Clean up
1802        let _ = fs::remove_dir_all(&test_dir);
1803
1804        println!("Corrupt JSON handling test passed");
1805    }
1806
1807    #[test]
1808    fn test_resume_data_bt_fields_optional() {
1809        // Create HTTP download (no BT fields)
1810        let http_data = create_sample_resume_data();
1811
1812        assert!(
1813            !http_data.is_bit_torrent(),
1814            "HTTP download should not be detected as BT"
1815        );
1816        assert!(
1817            http_data.bt_info_hash.is_none(),
1818            "HTTP download should have no BT hash"
1819        );
1820        assert!(
1821            http_data.bt_saved_metadata_path.is_none(),
1822            "HTTP download should have no BT metadata"
1823        );
1824        assert!(
1825            http_data.bitfield.is_empty(),
1826            "HTTP download should have empty bitfield"
1827        );
1828
1829        // Create BT download (with BT fields)
1830        let bt_data = create_bt_resume_data();
1831
1832        assert!(
1833            bt_data.is_bit_torrent(),
1834            "BT download should be detected as BT"
1835        );
1836        assert!(
1837            bt_data.bt_info_hash.is_some(),
1838            "BT download should have info hash"
1839        );
1840        assert!(
1841            bt_data.bt_saved_metadata_path.is_some(),
1842            "BT download should have metadata path"
1843        );
1844        assert!(
1845            !bt_data.bitfield.is_empty(),
1846            "BT download should have bitfield"
1847        );
1848
1849        // Roundtrip BT data to ensure BT fields persist
1850        let json = bt_data.serialize().expect("BT serialization failed");
1851        let restored_bt = ResumeData::deserialize(&json).expect("BT deserialization failed");
1852
1853        assert_eq!(
1854            restored_bt.bt_info_hash, bt_data.bt_info_hash,
1855            "BT hash should survive roundtrip"
1856        );
1857        assert_eq!(
1858            restored_bt.bitfield, bt_data.bitfield,
1859            "Bitfield should survive roundtrip"
1860        );
1861        assert_eq!(
1862            restored_bt.bt_saved_metadata_path, bt_data.bt_saved_metadata_path,
1863            "Metadata path should survive roundtrip"
1864        );
1865
1866        println!("BT optional fields test passed");
1867    }
1868
1869    #[test]
1870    fn test_resume_data_multiple_uris_preserved() {
1871        let data = create_sample_resume_data();
1872        assert_eq!(data.uris.len(), 3, "Sample data should have 3 URIs");
1873
1874        // Roundtrip through serialization
1875        let json = data.serialize().expect("Serialize failed");
1876        let restored = ResumeData::deserialize(&json).expect("Deserialize failed");
1877
1878        // Verify exact URI count
1879        assert_eq!(
1880            restored.uris.len(),
1881            3,
1882            "Should preserve 3 URIs after roundtrip"
1883        );
1884
1885        // Verify each URI's complete state
1886        // URI 1: Active, working
1887        assert_eq!(
1888            restored.uris[0].uri,
1889            "http://example.com/files/ubuntu-22.04-desktop-amd64.iso"
1890        );
1891        assert!(restored.uris[0].tried, "URI 1 should be marked as tried");
1892        assert!(restored.uris[0].used, "URI 1 should be marked as used");
1893        assert_eq!(restored.uris[0].last_result.as_deref(), Some("ok"));
1894        assert_eq!(restored.uris[0].speed_bytes_per_sec, Some(5 * 1024 * 1024));
1895
1896        // URI 2: Unused mirror
1897        assert_eq!(
1898            restored.uris[1].uri,
1899            "http://mirror.example.com/ubuntu-22.04-desktop-amd64.iso"
1900        );
1901        assert!(
1902            !restored.uris[1].tried,
1903            "URI 2 should NOT be marked as tried"
1904        );
1905        assert!(!restored.uris[1].used, "URI 2 should NOT be marked as used");
1906        assert!(
1907            restored.uris[1].last_result.is_none(),
1908            "URI 2 should have no result"
1909        );
1910        assert!(
1911            restored.uris[1].speed_bytes_per_sec.is_none(),
1912            "URI 2 should have no speed"
1913        );
1914
1915        // URI 3: Failed attempt
1916        assert_eq!(
1917            restored.uris[2].uri,
1918            "ftp://archive.ubuntu.com/ubuntu-22.04-desktop-amd64.iso"
1919        );
1920        assert!(restored.uris[2].tried, "URI 3 should be marked as tried");
1921        assert!(!restored.uris[2].used, "URI 3 should NOT be marked as used");
1922        assert_eq!(
1923            restored.uris[2].last_result.as_deref(),
1924            Some("Connection timeout")
1925        );
1926        assert!(
1927            restored.uris[2].speed_bytes_per_sec.is_none(),
1928            "URI 3 should have no speed"
1929        );
1930
1931        // Test edge case: Single URI
1932        let single_uri = ResumeData {
1933            gid: "single-uri-test".to_string(),
1934            uris: vec![UriState {
1935                uri: "http://example.com/single.file".to_string(),
1936                tried: true,
1937                used: true,
1938                last_result: Some("ok".to_string()),
1939                speed_bytes_per_sec: Some(1000),
1940            }],
1941            ..Default::default()
1942        };
1943
1944        let single_json = single_uri.serialize().unwrap();
1945        let single_restored = ResumeData::deserialize(&single_json).unwrap();
1946        assert_eq!(
1947            single_restored.uris.len(),
1948            1,
1949            "Single URI should be preserved"
1950        );
1951        assert_eq!(
1952            single_restored.uris[0].uri,
1953            "http://example.com/single.file"
1954        );
1955
1956        // Test edge case: Empty URI list
1957        let no_uris = ResumeData {
1958            gid: "no-uris-test".to_string(),
1959            uris: vec![],
1960            ..Default::default()
1961        };
1962
1963        let no_uris_json = no_uris.serialize().unwrap();
1964        let no_uris_restored = ResumeData::deserialize(&no_uris_json).unwrap();
1965        assert!(
1966            no_uris_restored.uris.is_empty(),
1967            "Empty URI list should be preserved"
1968        );
1969
1970        println!("Multiple URIs preservation test passed");
1971    }
1972
1973    #[test]
1974    fn test_completion_ratio_calculation() {
1975        // Normal case: 50% complete
1976        let data = ResumeData {
1977            total_length: 1000,
1978            completed_length: 500,
1979            ..Default::default()
1980        };
1981        assert!(
1982            (data.completion_ratio() - 0.5).abs() < f64::EPSILON,
1983            "50% should be 0.5"
1984        );
1985
1986        // Edge case: 0% complete
1987        let zero = ResumeData {
1988            total_length: 1000,
1989            completed_length: 0,
1990            ..Default::default()
1991        };
1992        assert_eq!(zero.completion_ratio(), 0.0, "0 bytes should be 0%");
1993
1994        // Edge case: 100% complete
1995        let full = ResumeData {
1996            total_length: 1000,
1997            completed_length: 1000,
1998            ..Default::default()
1999        };
2000        assert!(
2001            (full.completion_ratio() - 1.0).abs() < f64::EPSILON,
2002            "100% should be 1.0"
2003        );
2004
2005        // Edge case: Unknown total size (should return 0.0)
2006        let unknown = ResumeData {
2007            total_length: 0,
2008            completed_length: 500,
2009            ..Default::default()
2010        };
2011        assert_eq!(unknown.completion_ratio(), 0.0, "Unknown size should be 0%");
2012    }
2013
2014    #[test]
2015    fn test_get_filename_generation() {
2016        let data = ResumeData {
2017            gid: "abc123".to_string(),
2018            ..Default::default()
2019        };
2020        assert_eq!(data.get_filename(), "abc123.aria2");
2021
2022        let data2 = ResumeData {
2023            gid: "long-gid-with-dashes_and_underscores".to_string(),
2024            ..Default::default()
2025        };
2026        assert_eq!(
2027            data2.get_filename(),
2028            "long-gid-with-dashes_and_underscores.aria2"
2029        );
2030    }
2031
2032    #[test]
2033    fn test_default_values() {
2034        let data = ResumeData::default();
2035
2036        assert!(data.gid.is_empty(), "Default GID should be empty");
2037        assert!(data.uris.is_empty(), "Default URIs should be empty");
2038        assert_eq!(data.total_length, 0, "Default total_length should be 0");
2039        assert_eq!(
2040            data.completed_length, 0,
2041            "Default completed_length should be 0"
2042        );
2043        assert_eq!(
2044            data.uploaded_length, 0,
2045            "Default uploaded_length should be 0"
2046        );
2047        assert!(data.bitfield.is_empty(), "Default bitfield should be empty");
2048        assert_eq!(data.status, "waiting", "Default status should be 'waiting'");
2049        assert!(data.error_message.is_none(), "Default error should be None");
2050        assert_eq!(data.last_download_time, 0, "Default timestamp should be 0");
2051        assert_eq!(data.created_at, 0, "Default created_at should be 0");
2052        assert!(
2053            data.output_path.is_none(),
2054            "Default output_path should be None"
2055        );
2056        assert!(data.checksum.is_none(), "Default checksum should be None");
2057        assert!(data.options.is_empty(), "Default options should be empty");
2058        assert!(
2059            data.resume_offset.is_none(),
2060            "Default resume_offset should be None"
2061        );
2062        assert!(
2063            data.bt_info_hash.is_none(),
2064            "Default bt_info_hash should be None"
2065        );
2066        assert!(
2067            data.bt_saved_metadata_path.is_none(),
2068            "Default bt_metadata should be None"
2069        );
2070        assert!(
2071            data.num_pieces.is_none(),
2072            "Default num_pieces should be None"
2073        );
2074        assert!(
2075            data.piece_length.is_none(),
2076            "Default piece_length should be None"
2077        );
2078    }
2079
2080    // =====================================================================
2081    // Test Group 6: Integration test - crash -> restart -> recovery flow
2082    // =====================================================================
2083
2084    #[test]
2085    fn test_integration_crash_restart_recovery_flow() {
2086        // Simulate the complete lifecycle:
2087        // 1. Download starts, makes progress
2088        // 2. Process crashes (state saved to disk)
2089        // 3. Process restarts, loads saved state
2090        // 4. Validates state and prepares for restoration
2091
2092        let test_dir = create_test_dir();
2093        let resume_file = test_dir.join("crash_recovery_test.aria2");
2094
2095        // --- Phase 1: Simulate active download with progress ---
2096        let active_download = ResumeData {
2097            gid: "deadbeefcafebabe".to_string(),
2098            uris: vec![UriState {
2099                uri: "http://primary.server/big-release.iso".to_string(),
2100                tried: true,
2101                used: true,
2102                last_result: Some("ok".to_string()),
2103                speed_bytes_per_sec: Some(8 * 1024 * 1024),
2104            }],
2105            total_length: 2147483648,     // 2 GB
2106            completed_length: 1073741824, // 1 GB (50% done)
2107            uploaded_length: 0,
2108            bitfield: vec![],
2109            num_pieces: None,
2110            piece_length: None,
2111            status: "active".to_string(),
2112            error_message: None,
2113            last_download_time: 1700010000,
2114            created_at: 1700009000,
2115            output_path: Some("/downloads/big-release.iso".to_string()),
2116            checksum: Some(ChecksumInfo {
2117                algorithm: "sha-256".to_string(),
2118                expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2119                    .to_string(),
2120            }),
2121            options: {
2122                let mut m = HashMap::new();
2123                m.insert("split".to_string(), "8".to_string());
2124                m.insert("max-connection-per-server".to_string(), "4".to_string());
2125                m.insert("dir".to_string(), "/downloads".to_string());
2126                m
2127            },
2128            resume_offset: Some(1073741824),
2129            bt_info_hash: None,
2130            bt_saved_metadata_path: None,
2131        };
2132
2133        // --- Phase 2: Simulate crash - save state to disk ---
2134        active_download
2135            .save_to_file(&resume_file)
2136            .expect("Crash-save should succeed");
2137        assert!(
2138            resume_file.exists(),
2139            "Resume file must exist after crash-save"
2140        );
2141
2142        // --- Phase 3: Simulate restart - load state from disk ---
2143        let loaded = ResumeData::load_from_file(&resume_file)
2144            .expect("Load should succeed")
2145            .expect("Resume data should exist after crash");
2146
2147        // --- Phase 4: Validate loaded state ---
2148        assert_eq!(
2149            loaded.gid, "deadbeefcafebabe",
2150            "GID must match after crash/restart"
2151        );
2152        assert_eq!(
2153            loaded.completed_length, 1073741824,
2154            "Progress must be preserved across crash"
2155        );
2156        assert_eq!(loaded.status, "active", "Status must be preserved");
2157        assert_eq!(
2158            loaded.resume_offset,
2159            Some(1073741824),
2160            "Resume offset must allow continuation from where we stopped"
2161        );
2162
2163        // --- Phase 5: Prepare for restoration ---
2164        let validation = loaded.validate_for_restore();
2165        assert!(
2166            validation.is_ok(),
2167            "Saved state must pass validation for restoration: {:?}",
2168            validation.err()
2169        );
2170
2171        // Decompose into restore components
2172        let (gid, uris, options, restore_state) = loaded.to_restore_components();
2173
2174        assert_eq!(gid, "deadbeefcafebabe", "Restoration GID must match");
2175        assert_eq!(uris.len(), 1, "URI must be available for restoration");
2176        assert!(
2177            options.contains_key("split"),
2178            "Options must include split setting"
2179        );
2180
2181        // Verify correct restore state variant
2182        match restore_state {
2183            RestoreState::HttpFtp {
2184                resume_offset,
2185                total_length,
2186                completed_length,
2187            } => {
2188                assert_eq!(resume_offset, 1073741824, "HTTP resume offset must match");
2189                assert_eq!(total_length, 2147483648, "Total length must match");
2190                assert_eq!(completed_length, 1073741824, "Completed must match");
2191            }
2192            other => panic!("Expected HttpFtp restore state, got: {:?}", other),
2193        }
2194
2195        // --- Phase 6: Verify data is ready for restoration ---
2196        let validation = loaded.validate_for_restore();
2197        assert!(
2198            validation.is_ok(),
2199            "Loaded data should be valid for restoration: {:?}",
2200            validation.err()
2201        );
2202
2203        // Clean up
2204        let _ = fs::remove_dir_all(&test_dir);
2205
2206        println!("Integration crash->restart->recovery flow test passed");
2207    }
2208
2209    #[tokio::test]
2210    async fn test_integration_from_request_group_roundtrip() {
2211        // Test the full pipeline: RequestGroup -> ResumeData -> file -> load -> validate
2212
2213        let group = RequestGroup::new(
2214            GroupId::new(0xDEADBEEF),
2215            vec![
2216                "http://example.com/test-file.bin".to_string(),
2217                "http://mirror.example.com/test-file.bin".to_string(),
2218            ],
2219            {
2220                DownloadOptions {
2221                    split: Some(4),
2222                    dir: Some("/downloads".to_string()),
2223                    out: Some("test-file.bin".to_string()),
2224                    checksum: Some((
2225                        "sha-256".to_string(),
2226                        "abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890"
2227                            .to_string(),
2228                    )),
2229                    ..DownloadOptions::default()
2230                }
2231            },
2232        );
2233
2234        // Simulate some download progress
2235        group.set_total_length_atomic(104857600); // 100 MB
2236        group.set_completed_length(52428800); // 50 MB downloaded
2237        group.set_uploaded_length(0);
2238        group.set_download_speed_cached(5242880); // 5 MB/s
2239        group.set_resume_offset(52428800);
2240
2241        // Extract resume data from the live RequestGroup
2242        let resume_data: ResumeData = <ResumeData as ResumeDataExt>::from_request_group(&group)
2243            .await
2244            .expect("Extraction from RequestGroup should succeed");
2245
2246        // Verify extraction produced valid data
2247        assert_eq!(
2248            resume_data.gid,
2249            group.gid().to_hex_string(),
2250            "GID should match"
2251        );
2252        assert_eq!(
2253            resume_data.total_length, 104857600,
2254            "Total length should match"
2255        );
2256        assert_eq!(
2257            resume_data.completed_length, 52428800,
2258            "Completed length should match"
2259        );
2260        assert_eq!(resume_data.uris.len(), 2, "Both URIs should be extracted");
2261        assert!(
2262            resume_data.checksum.is_some(),
2263            "Checksum should be extracted from options"
2264        );
2265
2266        // Validate the extracted data
2267        resume_data
2268            .validate_for_restore()
2269            .expect("Extracted data should be valid for restoration");
2270
2271        // Roundtrip through serialization
2272        let json = resume_data.serialize().unwrap();
2273        let restored = ResumeData::deserialize(&json).unwrap();
2274
2275        assert_eq!(restored.gid, resume_data.gid, "Roundtrip GID should match");
2276        assert_eq!(
2277            restored.completed_length, resume_data.completed_length,
2278            "Roundtrip completed_length should match"
2279        );
2280
2281        println!(
2282            "RequestGroup -> ResumeData roundtrip test passed. GID: {}",
2283            resume_data.gid
2284        );
2285    }
2286
2287    #[tokio::test]
2288    async fn test_integration_bt_request_group_extraction() {
2289        // Test BT-specific extraction from RequestGroup with bitfield
2290
2291        let group = RequestGroup::new(
2292            GroupId::new(0xB7C01234),
2293            vec![
2294                "magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=TestTorrent"
2295                    .to_string(),
2296            ],
2297            {
2298                DownloadOptions {
2299                    seed_time: Some(3600),
2300                    seed_ratio: Some(1.5),
2301                    enable_dht: true,
2302                    ..DownloadOptions::default()
2303                }
2304            },
2305        );
2306
2307        // Set BT-specific state
2308        group.set_total_length_atomic(1073741824); // 1 GB
2309        group.set_completed_length(536870912); // 512 MB
2310        group.set_uploaded_length(134217728); // 128 MB seeded
2311        group
2312            .set_bt_bitfield(Some(vec![0xFF, 0xFF, 0x00, 0x00]))
2313            .await;
2314
2315        // Extract
2316        let resume_data: ResumeData = <ResumeData as ResumeDataExt>::from_request_group(&group)
2317            .await
2318            .expect("BT extraction should succeed");
2319
2320        // Verify BT detection
2321        assert!(resume_data.is_bit_torrent(), "Should be detected as BT");
2322        assert_eq!(resume_data.detect_protocol(), "bt");
2323        assert_eq!(
2324            resume_data.bitfield,
2325            vec![0xFF, 0xFF, 0x00, 0x00],
2326            "Bitfield should be extracted"
2327        );
2328        assert_eq!(
2329            resume_data.uploaded_length, 134217728,
2330            "Upload should be tracked"
2331        );
2332
2333        // Verify info hash extracted from magnet
2334        assert!(
2335            resume_data.bt_info_hash.is_some(),
2336            "Info hash should be extracted from magnet URI"
2337        );
2338
2339        // Verify restore components produce BT variant
2340        let (_, _, _, restore_state) = resume_data.to_restore_components();
2341        match restore_state {
2342            RestoreState::BitTorrent { bitfield, .. } => {
2343                assert_eq!(bitfield, vec![0xFF, 0xFF, 0x00, 0x00]);
2344            }
2345            other => panic!("Expected BitTorrent restore state, got: {:?}", other),
2346        }
2347
2348        println!("BT RequestGroup extraction test passed");
2349    }
2350}