1use 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
31use crate::request::request_group::{DownloadStatus, RequestGroup};
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ResumeData {
74 pub gid: String,
77
78 pub uris: Vec<UriState>,
81
82 pub total_length: u64,
85
86 pub completed_length: u64,
88
89 pub uploaded_length: u64,
91
92 pub bitfield: Vec<u8>,
94
95 pub num_pieces: Option<u32>,
97
98 pub piece_length: Option<u32>,
100
101 pub status: String,
104
105 pub error_message: Option<String>,
107
108 pub last_download_time: u64,
111
112 pub created_at: u64,
114
115 pub output_path: Option<String>,
118
119 pub checksum: Option<ChecksumInfo>,
121
122 pub options: HashMap<String, String>,
125
126 pub resume_offset: Option<u64>,
129
130 pub bt_info_hash: Option<String>,
133
134 pub bt_saved_metadata_path: Option<String>,
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct UriState {
144 pub uri: String,
146
147 pub tried: bool,
149
150 pub used: bool,
152
153 pub last_result: Option<String>,
155
156 pub speed_bytes_per_sec: Option<u64>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ChecksumInfo {
165 pub algorithm: String,
167
168 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 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 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 pub fn save_to_file(&self, path: &Path) -> Result<(), String> {
280 let json = self.serialize()?;
281
282 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 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 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 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 pub fn is_bit_torrent(&self) -> bool {
379 self.bt_info_hash.is_some() || !self.bitfield.is_empty()
380 }
381
382 pub fn is_metalink(&self) -> bool {
386 self.uris.len() > 1
387 }
388
389 pub fn get_filename(&self) -> String {
393 format!("{}.aria2", self.gid)
394 }
395
396 pub fn validate_for_restore(&self) -> Result<(), String> {
401 if self.gid.is_empty() {
403 return Err("GID must not be empty".to_string());
404 }
405
406 if self.uris.is_empty() {
408 return Err("At least one URI is required".to_string());
409 }
410
411 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 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 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 }
439 }
440
441 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 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
481pub trait ResumeDataExt: Sized {
491 fn from_request_group(
514 group: &RequestGroup,
515 ) -> impl std::future::Future<Output = Result<Self, String>> + Send;
516
517 fn to_restore_components(
527 &self,
528 ) -> (
529 String, Vec<String>, HashMap<String, String>, RestoreState, );
534}
535
536#[derive(Debug, Clone)]
541pub enum RestoreState {
542 HttpFtp {
544 resume_offset: u64,
546 total_length: u64,
548 completed_length: u64,
550 },
551
552 BitTorrent {
554 bitfield: Vec<u8>,
556 num_pieces: Option<u32>,
558 piece_length: Option<u32>,
560 info_hash: Option<String>,
562 metadata_path: Option<String>,
564 },
565
566 Metalink {
568 mirrors: Vec<MirrorRestoreInfo>,
570 resume_offset: Option<u64>,
572 },
573}
574
575#[derive(Debug, Clone)]
580pub struct MirrorRestoreInfo {
581 pub uri: String,
583 pub tried: bool,
585 pub last_result: Option<String>,
587 pub speed_bytes_per_sec: Option<u64>,
589 pub priority_score: u32,
591}
592
593impl ResumeDataExt for ResumeData {
594 async fn from_request_group(group: &RequestGroup) -> Result<Self, String> {
595 let gid = group.gid().to_hex_string();
597
598 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, used: false, last_result: None,
607 speed_bytes_per_sec: None,
608 })
609 .collect();
610
611 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 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 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 let created_at = SystemTime::now()
641 .duration_since(UNIX_EPOCH)
642 .unwrap_or_default()
643 .as_secs();
644 let last_download_time = created_at; let options = group.options();
648 let output_path = options.out.clone().or_else(|| {
649 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 let checksum = options
664 .checksum
665 .as_ref()
666 .map(|(algo, expected)| ChecksumInfo {
667 algorithm: algo.clone(),
668 expected: expected.clone(),
669 });
670
671 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 let bt_bitfield = group.get_bt_bitfield().await;
694
695 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 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 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, piece_length: None, 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 let mirrors: Vec<MirrorRestoreInfo> = self
767 .uris
768 .iter()
769 .enumerate()
770 .map(|(i, u)| {
771 let mut priority = i as u32 * 10;
773 if u.tried && u.last_result.as_deref() == Some("ok") {
774 priority = 0; } else if !u.tried {
776 priority += 5; } else if u.last_result.is_some() {
778 priority += 20; }
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 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 fn extract_info_hash_from_magnet(magnet_uri: &str) -> Option<String> {
817 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 if hash.len() >= 20 {
826 Some(hash.to_string())
827 } else {
828 None
829 }
830 }
831}
832
833#[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 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 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), },
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, completed_length: 2352892928, 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(), }),
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 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, completed_length: 536870912, uploaded_length: 134217728, bitfield: vec![0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00], num_pieces: Some(64),
928 piece_length: Some(16777216), 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 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), },
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), },
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, completed_length: 262144000, 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 #[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 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 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 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, 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 #[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 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 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 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); 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 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 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 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 #[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 assert_eq!(
1337 restored.uris.len(),
1338 4,
1339 "All 4 mirrors must survive roundtrip"
1340 );
1341
1342 assert!(
1344 restored.is_metalink(),
1345 "Multiple URIs should be detected as metalink"
1346 );
1347 assert_eq!(restored.detect_protocol(), "metalink");
1348
1349 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 let (_gid, _uris, _options, restore_state) = ml.to_restore_components();
1368
1369 match restore_state {
1370 RestoreState::Metalink { mirrors, .. } => {
1371 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 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 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 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 #[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, ..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 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 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 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 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 #[test]
1649 fn test_resume_data_serialize_deserialize_full_roundtrip() {
1650 let original = create_sample_resume_data();
1651
1652 let json = original.serialize().expect("Serialization failed");
1654
1655 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 let restored = ResumeData::deserialize(&json).expect("Deserialization failed");
1669
1670 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 original.save_to_file(&file_path).expect("Save failed");
1714
1715 assert!(file_path.exists(), "Resume file should exist after save");
1717
1718 let loaded = ResumeData::load_from_file(&file_path)
1720 .expect("Load failed")
1721 .expect("Should have returned Some(data)");
1722
1723 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 let tmp_path = file_path.with_extension("aria2.tmp");
1745 assert!(!tmp_path.exists(), "No temporary file should remain");
1746
1747 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 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 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 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 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 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 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 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 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 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 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 let json = data.serialize().expect("Serialize failed");
1876 let restored = ResumeData::deserialize(&json).expect("Deserialize failed");
1877
1878 assert_eq!(
1880 restored.uris.len(),
1881 3,
1882 "Should preserve 3 URIs after roundtrip"
1883 );
1884
1885 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 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 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 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 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 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 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 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 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 #[test]
2085 fn test_integration_crash_restart_recovery_flow() {
2086 let test_dir = create_test_dir();
2093 let resume_file = test_dir.join("crash_recovery_test.aria2");
2094
2095 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, completed_length: 1073741824, 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 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 let loaded = ResumeData::load_from_file(&resume_file)
2144 .expect("Load should succeed")
2145 .expect("Resume data should exist after crash");
2146
2147 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 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 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 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 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 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 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 group.set_total_length_atomic(104857600); group.set_completed_length(52428800); group.set_uploaded_length(0);
2238 group.set_download_speed_cached(5242880); group.set_resume_offset(52428800);
2240
2241 let resume_data: ResumeData = <ResumeData as ResumeDataExt>::from_request_group(&group)
2243 .await
2244 .expect("Extraction from RequestGroup should succeed");
2245
2246 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 resume_data
2268 .validate_for_restore()
2269 .expect("Extracted data should be valid for restoration");
2270
2271 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 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 group.set_total_length_atomic(1073741824); group.set_completed_length(536870912); group.set_uploaded_length(134217728); group
2312 .set_bt_bitfield(Some(vec![0xFF, 0xFF, 0x00, 0x00]))
2313 .await;
2314
2315 let resume_data: ResumeData = <ResumeData as ResumeDataExt>::from_request_group(&group)
2317 .await
2318 .expect("BT extraction should succeed");
2319
2320 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 assert!(
2335 resume_data.bt_info_hash.is_some(),
2336 "Info hash should be extracted from magnet URI"
2337 );
2338
2339 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}