Skip to main content

aria2_core/session/
session_entry.rs

1//! Session Entry module - Core data structure for session serialization
2//!
3//! This module provides the `SessionEntry` struct which represents a single download
4//! task's state that can be serialized to and deserialized from session files.
5//!
6//! # Overview
7//!
8//! A `SessionEntry` captures all necessary information about an active or paused download:
9//! - **URIs**: One or more source URLs (mirrors) for the download
10//! - **GID**: Unique identifier for the download task
11//! - **Options**: Download configuration options (split, dir, out, etc.)
12//! - **Progress**: Current download/upload statistics
13//! - **Status**: Active state of the download (active, waiting, paused, error)
14//! - **BT-specific**: Bitfield and piece information for BitTorrent downloads
15//!
16//! # Architecture
17//!
18//! ```text
19//! session_entry.rs (this file)
20//!   ├── SessionEntry struct definition
21//!   ├── Builder pattern methods (new, with_options, paused)
22//!   └── Re-exports for convenience
23//!
24//! session_serialize_impl.rs
25//!   └── impl SessionEntry { serialize(), deserialize_line() }
26//!
27//! session_uri_utils.rs
28//!   └── escape_uri(), unescape_uri(), decode_hex()
29//!
30//! session_options.rs
31//!   └── download_options_to_map()
32//! ```
33//!
34//! # Examples
35//!
36//! ```rust
37//! use aria2_core::session::session_entry::SessionEntry;
38//! use std::collections::HashMap;
39//!
40//! // Create a basic entry
41//! let entry = SessionEntry::new(1, vec!["http://example.com/file.zip".to_string()]);
42//!
43//! // Add options using builder pattern
44//! let entry = SessionEntry::new(2, vec!["http://example.com/big.iso".to_string()])
45//!     .with_options({
46//!         let mut opts = HashMap::new();
47//!         opts.insert("split".to_string(), "4".to_string());
48//!         opts.insert("dir".to_string(), "/downloads".to_string());
49//!         opts
50//!     })
51//!     .paused();
52//! ```
53
54use std::collections::HashMap;
55
56use crate::error::{Aria2Error, Result};
57use crate::request::request_group::DownloadOptions;
58
59// =========================================================================
60// URI Utility Functions (consolidated from session_uri_utils.rs)
61// =========================================================================
62
63/// Escapes special characters in URIs for safe serialization.
64pub fn escape_uri(s: &str) -> String {
65    s.replace('\\', "\\\\")
66        .replace('\t', "\\t")
67        .replace('\n', "\\n")
68}
69
70/// Unescapes special characters previously escaped by [`escape_uri()`].
71pub fn unescape_uri(s: &str) -> String {
72    let mut result = String::with_capacity(s.len());
73    let mut chars = s.chars().peekable();
74
75    while let Some(c) = chars.next() {
76        if c == '\\' {
77            if let Some(&next) = chars.peek() {
78                match next {
79                    't' => {
80                        result.push('\t');
81                        chars.next();
82                    }
83                    'n' => {
84                        result.push('\n');
85                        chars.next();
86                    }
87                    '\\' => {
88                        result.push('\\');
89                        chars.next();
90                    }
91                    _ => {
92                        result.push(c);
93                    }
94                }
95            } else {
96                result.push(c);
97            }
98        } else {
99            result.push(c);
100        }
101    }
102
103    result
104}
105
106/// Decodes a hexadecimal string to a byte vector.
107pub fn decode_hex(hex: &str) -> Result<Vec<u8>> {
108    if !hex.len().is_multiple_of(2) {
109        return Err(Aria2Error::Io(format!(
110            "Hex string has odd length: {}",
111            hex.len()
112        )));
113    }
114
115    let mut bytes = Vec::with_capacity(hex.len() / 2);
116
117    for i in (0..hex.len()).step_by(2) {
118        let byte_str = &hex[i..i + 2];
119        let byte = u8::from_str_radix(byte_str, 16).map_err(|e| {
120            Aria2Error::Io(format!("Invalid hex character at position {}: {}", i, e))
121        })?;
122        bytes.push(byte);
123    }
124
125    Ok(bytes)
126}
127
128// =========================================================================
129// Options Conversion (consolidated from session_options.rs)
130// =========================================================================
131
132/// Converts DownloadOptions struct to a HashMap for serialization.
133///
134/// Only non-default / non-empty values are included to keep the session file
135/// compact. The load path (`map_entry_to_download_options`) uses
136/// `unwrap_or(default)` for every field, so absent keys are safe.
137pub fn download_options_to_map(opts: &DownloadOptions) -> HashMap<String, String> {
138    let mut map = HashMap::new();
139
140    // --- Basic download options ---
141    if let Some(v) = opts.split {
142        map.insert("split".to_string(), v.to_string());
143    }
144    if let Some(v) = opts.max_connection_per_server {
145        map.insert("max-connection-per-server".to_string(), v.to_string());
146    }
147    if let Some(v) = opts.max_download_limit {
148        map.insert("max-download-limit".to_string(), v.to_string());
149    }
150    if let Some(v) = opts.max_upload_limit {
151        map.insert("max-upload-limit".to_string(), v.to_string());
152    }
153    if let Some(ref v) = opts.dir {
154        map.insert("dir".to_string(), v.clone());
155    }
156    if let Some(ref v) = opts.out {
157        map.insert("out".to_string(), v.clone());
158    }
159    if let Some(v) = opts.seed_time {
160        map.insert("seed-time".to_string(), v.to_string());
161    }
162    if let Some(v) = opts.seed_ratio {
163        map.insert("seed-ratio".to_string(), v.to_string());
164    }
165
166    // --- File allocation ---
167    if let Some(ref v) = opts.file_allocation {
168        map.insert("file-allocation".to_string(), v.clone());
169    }
170    if let Some(v) = opts.mmap_threshold {
171        map.insert("mmap-threshold".to_string(), v.to_string());
172    }
173    if opts.secure_falloc {
174        map.insert("secure-falloc".to_string(), "true".to_string());
175    }
176
177    // --- Checksum ---
178    if let Some((ref algo, ref val)) = opts.checksum {
179        map.insert("checksum".to_string(), format!("{}={}", algo, val));
180    }
181
182    // --- Cookies ---
183    if let Some(ref v) = opts.cookie_file {
184        map.insert("cookie-file".to_string(), v.clone());
185    }
186    if let Some(ref v) = opts.cookies {
187        map.insert("cookies".to_string(), v.clone());
188    }
189
190    // --- BitTorrent options ---
191    if opts.bt_force_encrypt {
192        map.insert("bt-force-encrypt".to_string(), "true".to_string());
193    }
194    if opts.bt_require_crypto {
195        map.insert("bt-require-crypto".to_string(), "true".to_string());
196    }
197    // enable_dht defaults to true; only save if disabled
198    if !opts.enable_dht {
199        map.insert("enable-dht".to_string(), "false".to_string());
200    }
201    if let Some(v) = opts.dht_listen_port {
202        map.insert("dht-listen-port".to_string(), v.to_string());
203    }
204    if let Some(ref v) = opts.dht_entry_point {
205        map.insert("dht-entry-point".to_string(), v.join(","));
206    }
207    // enable_public_trackers defaults to true; only save if disabled
208    if !opts.enable_public_trackers {
209        map.insert("enable-public-trackers".to_string(), "false".to_string());
210    }
211    if !opts.bt_piece_selection_strategy.is_empty() {
212        map.insert(
213            "bt-piece-selection-strategy".to_string(),
214            opts.bt_piece_selection_strategy.clone(),
215        );
216    }
217    if opts.bt_endgame_threshold > 0 {
218        map.insert(
219            "bt-endgame-threshold".to_string(),
220            opts.bt_endgame_threshold.to_string(),
221        );
222    }
223    if let Some(v) = opts.bt_max_upload_slots {
224        map.insert("bt-max-upload-slots".to_string(), v.to_string());
225    }
226    if let Some(v) = opts.bt_optimistic_unchoke_interval {
227        map.insert("bt-optimistic-unchoke-interval".to_string(), v.to_string());
228    }
229    if let Some(v) = opts.bt_snubbed_timeout {
230        map.insert("bt-snubbed-timeout".to_string(), v.to_string());
231    }
232    if !opts.bt_prioritize_piece.is_empty() {
233        map.insert(
234            "bt-prioritize-piece".to_string(),
235            opts.bt_prioritize_piece.clone(),
236        );
237    }
238    if opts.enable_utp {
239        map.insert("enable-utp".to_string(), "true".to_string());
240    }
241    if let Some(v) = opts.utp_listen_port {
242        map.insert("utp-listen-port".to_string(), v.to_string());
243    }
244
245    // --- Retry options ---
246    if opts.max_retries > 0 {
247        map.insert("max-retries".to_string(), opts.max_retries.to_string());
248    }
249    if opts.retry_wait > 0 {
250        map.insert("retry-wait".to_string(), opts.retry_wait.to_string());
251    }
252
253    // --- DHT file path ---
254    if let Some(ref v) = opts.dht_file_path {
255        map.insert("dht-file-path".to_string(), v.clone());
256    }
257
258    // --- Proxy options ---
259    if let Some(ref v) = opts.http_proxy {
260        map.insert("http-proxy".to_string(), v.clone());
261    }
262    if let Some(ref v) = opts.all_proxy {
263        map.insert("all-proxy".to_string(), v.clone());
264    }
265    if let Some(ref v) = opts.https_proxy {
266        map.insert("https-proxy".to_string(), v.clone());
267    }
268    if let Some(ref v) = opts.ftp_proxy {
269        map.insert("ftp-proxy".to_string(), v.clone());
270    }
271    if let Some(ref v) = opts.no_proxy {
272        map.insert("no-proxy".to_string(), v.clone());
273    }
274
275    // --- HTTP headers ---
276    if !opts.header.is_empty() {
277        map.insert("header".to_string(), opts.header.join(","));
278    }
279    if let Some(ref v) = opts.user_agent {
280        map.insert("user-agent".to_string(), v.clone());
281    }
282    if let Some(ref v) = opts.referer {
283        map.insert("referer".to_string(), v.clone());
284    }
285
286    map
287}
288
289/// Represents a single download task in a session file
290///
291/// This struct contains all information needed to resume a download task,
292/// including URIs, options, current progress, and status.
293///
294/// # Fields
295///
296/// * `gid` - Unique global identifier for this download task
297/// * `uris` - List of source URLs (primary URL + mirrors)
298/// * `options` - Download configuration options as key-value pairs
299/// * `paused` - Whether this download is currently paused
300/// * `total_length` - Total size of the download in bytes
301/// * `completed_length` - Number of bytes already downloaded
302/// * `upload_length` - Number of bytes uploaded (for seeding)
303/// * `download_speed` - Current download speed in bytes/sec
304/// * `status` - Current status: "active", "waiting", "paused", or "error"
305/// * `error_code` - Error code if status is "error"
306/// * `bitfield` - BitTorrent piece completion bitmap (BT only)
307/// * `num_pieces` - Number of pieces in torrent (BT only)
308/// * `piece_length` - Size of each piece in bytes (BT only)
309/// * `info_hash_hex` - Torrent info hash hex string (BT only)
310/// * `resume_offset` - File offset for HTTP/FTP resume support
311#[derive(Debug, Clone)]
312pub struct SessionEntry {
313    /// Unique global identifier for this download task
314    pub gid: u64,
315
316    /// List of source URIs (primary URL + mirrors), tab-separated in serialized form
317    pub uris: Vec<String>,
318
319    /// Download configuration options as key-value pairs
320    pub options: HashMap<String, String>,
321
322    /// Whether this download is currently paused
323    pub paused: bool,
324
325    // ==================== Progress & Status Fields ====================
326    /// Total size of the download in bytes (0 if unknown)
327    pub total_length: u64,
328
329    /// Number of bytes already downloaded and verified
330    pub completed_length: u64,
331
332    /// Number of bytes uploaded (relevant for BitTorrent seeding)
333    pub upload_length: u64,
334
335    /// Current download speed in bytes/second
336    pub download_speed: u64,
337
338    /// Current status of the download: "active", "waiting", "paused", "error"
339    pub status: String,
340
341    /// Error code if the download is in error state
342    pub error_code: Option<i32>,
343
344    // ==================== BitTorrent-Specific Fields ====================
345    // These fields are only populated for BitTorrent downloads
346    /// Completed piece bitmap encoded as hex string in file format
347    /// None for non-BT downloads
348    pub bitfield: Option<Vec<u8>>,
349
350    /// Total number of pieces in the torrent
351    /// None for non-BT downloads
352    pub num_pieces: Option<u32>,
353
354    /// Size of each piece in bytes
355    /// None for non-BT downloads
356    pub piece_length: Option<u32>,
357
358    /// Info hash of the torrent (hex string) for matching torrent files
359    /// None for non-BT downloads
360    pub info_hash_hex: Option<String>,
361
362    // ==================== HTTP/FTP Resume Support ====================
363    /// File offset where download should resume (for HTTP/FTP range requests)
364    /// None if resumption is not applicable
365    pub resume_offset: Option<u64>,
366}
367
368impl SessionEntry {
369    /// Creates a new SessionEntry with default values
370    ///
371    /// # Arguments
372    ///
373    /// * `gid` - Unique identifier for this download task
374    /// * `uris` - List of source URLs (at least one required)
375    ///
376    /// # Returns
377    ///
378    /// A new `SessionEntry` instance with sensible defaults:
379    /// - `paused`: false
380    /// - All progress fields: 0
381    /// - `status`: "active"
382    /// - All optional fields: None
383    ///
384    /// # Example
385    ///
386    /// ```rust
387    /// use aria2_core::session::session_entry::SessionEntry;
388    ///
389    /// let entry = SessionEntry::new(1, vec!["http://example.com/file.zip".to_string()]);
390    /// assert_eq!(entry.gid, 1);
391    /// assert_eq!(entry.uris.len(), 1);
392    /// assert!(!entry.paused);
393    /// assert_eq!(entry.status, "active");
394    /// ```
395    pub fn new(gid: u64, uris: Vec<String>) -> Self {
396        SessionEntry {
397            gid,
398            uris,
399            options: HashMap::new(),
400            paused: false,
401
402            // Default values for progress fields
403            total_length: 0,
404            completed_length: 0,
405            upload_length: 0,
406            download_speed: 0,
407            status: "active".to_string(),
408            error_code: None,
409
410            // BT-specific fields (None for non-BT downloads by default)
411            bitfield: None,
412            num_pieces: None,
413            piece_length: None,
414            info_hash_hex: None,
415
416            // HTTP/FTP resume info (None by default)
417            resume_offset: None,
418        }
419    }
420
421    /// Sets download options using builder pattern
422    ///
423    /// # Arguments
424    ///
425    /// * `options` - HashMap of option key-value pairs
426    ///
427    /// # Returns
428    ///
429    /// Self for method chaining
430    ///
431    /// # Example
432    ///
433    /// ```rust
434    /// use aria2_core::session::session_entry::SessionEntry;
435    /// use std::collections::HashMap;
436    ///
437    /// let mut opts = HashMap::new();
438    /// opts.insert("split".to_string(), "4".to_string());
439    /// opts.insert("dir".to_string(), "/downloads".to_string());
440    ///
441    /// let entry = SessionEntry::new(1, vec!["http://example.com/f".to_string()])
442    ///     .with_options(opts);
443    /// assert_eq!(entry.options.get("split").unwrap(), "4");
444    /// ```
445    pub fn with_options(mut self, options: HashMap<String, String>) -> Self {
446        self.options = options;
447        self
448    }
449
450    /// Marks this entry as paused using builder pattern
451    ///
452    /// # Returns
453    ///
454    /// Self for method chaining
455    ///
456    /// # Example
457    ///
458    /// ```no_run
459    /// use aria2_core::session::session_entry::SessionEntry;
460    ///
461    /// let entry = SessionEntry::new(1, vec!["http://example.com/f".to_string()])
462    ///     .paused();
463    /// assert!(entry.paused);
464    /// ```
465    pub fn paused(mut self) -> Self {
466        self.paused = true;
467        self
468    }
469
470    /// Gets an option value by key
471    ///
472    /// # Arguments
473    ///
474    /// * `key` - Option key to look up
475    ///
476    /// # Returns
477    ///
478    /// Some(&str) if the key exists, None otherwise
479    #[allow(dead_code)] // Utility method for option retrieval, available for future use
480    fn get_option(&self, key: &str) -> Option<&str> {
481        self.options.get(key).map(|s| s.as_str())
482    }
483
484    // Note: serialize() and deserialize_line() are now implemented in
485    // session_serialize_impl.rs as part of impl SessionEntry
486    // They are available via the impl block there and accessible normally
487}
488
489// ==================== Unit Tests ====================
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn test_serialize_single_entry() {
497        let entry = SessionEntry::new(0xd270c8a2, vec!["http://example.com/file.zip".to_string()]);
498        let text = entry.serialize();
499        assert!(
500            text.contains("http://example.com/file.zip"),
501            "Should contain URI"
502        );
503        assert!(text.contains("GID=d270c8a2"), "Should contain GID");
504    }
505
506    #[test]
507    fn test_serialize_multiple_entries_roundtrip() {
508        let entries = vec![
509            SessionEntry::new(1, vec!["http://a.com/1.bin".to_string()]).with_options({
510                let mut m = HashMap::new();
511                m.insert("split".to_string(), "4".to_string());
512                m.insert("dir".to_string(), "/tmp".to_string());
513                m
514            }),
515            SessionEntry::new(
516                2,
517                vec![
518                    "ftp://b.com/2.iso".to_string(),
519                    "http://mirror.b.com/2.iso".to_string(),
520                ],
521            )
522            .paused(),
523        ];
524
525        let mut serialized = String::new();
526        for e in &entries {
527            serialized.push_str(&e.serialize());
528            serialized.push('\n');
529        }
530
531        // Parse individually using deserialize_line
532        let parts: Vec<&str> = serialized.split("\n\n").collect();
533        assert!(parts.len() >= 2, "Should have at least 2 entries");
534
535        let entry1 = SessionEntry::deserialize_line(parts[0]).unwrap();
536        assert_eq!(entry1.uris.len(), 1);
537        assert_eq!(entry1.uris[0], "http://a.com/1.bin");
538        assert_eq!(entry1.options.get("split").unwrap(), "4");
539
540        let entry2 = SessionEntry::deserialize_line(parts[1]).unwrap();
541        assert_eq!(entry2.uris.len(), 2);
542        assert!(entry2.paused);
543    }
544
545    #[test]
546    fn test_deserialize_empty_file() {
547        let entry = SessionEntry::deserialize_line("").unwrap();
548        assert!(entry.uris.is_empty());
549
550        let entry = SessionEntry::deserialize_line("\n\n\n").unwrap();
551        assert!(entry.uris.is_empty());
552    }
553
554    #[test]
555    fn test_deserialize_skip_comments_and_blanks() {
556        let input = r#"# This is a comment
557# Another comment
558
559http://example.com/file
560 GID=abc123
561 dir=/downloads
562"#;
563        let entry = SessionEntry::deserialize_line(input).unwrap();
564        // Should parse first entry and ignore comments
565        assert_eq!(entry.uris.len(), 1);
566        assert_eq!(entry.uris[0], "http://example.com/file");
567    }
568
569    #[test]
570    fn test_deserialize_options_parsing() {
571        let input = r#"http://example.com/file.zip
572 GID=1
573 split=4
574 max-connection-per-server=2
575 dir=C:\Users\test\Downloads
576 out=file.zip
577"#;
578        let entry = SessionEntry::deserialize_line(input).unwrap();
579        assert_eq!(entry.options.get("split").unwrap(), "4");
580        assert_eq!(entry.options.get("max-connection-per-server").unwrap(), "2");
581        assert_eq!(
582            entry.options.get("dir").unwrap(),
583            "C:\\Users\\test\\Downloads"
584        );
585        assert_eq!(entry.options.get("out").unwrap(), "file.zip");
586    }
587
588    #[test]
589    fn test_pause_flag_serialization() {
590        let input = r#"http://example.com/pause.me
591 GID=42
592 PAUSE=true
593"#;
594        let entry = SessionEntry::deserialize_line(input).unwrap();
595        assert!(entry.paused);
596
597        let text = entry.serialize();
598        assert!(text.contains("PAUSE=true"));
599    }
600
601    #[test]
602    fn test_serialize_tab_separated_uris() {
603        let entry = SessionEntry::new(
604            99,
605            vec![
606                "http://mirror1.com/f".to_string(),
607                "http://mirror2.com/f".to_string(),
608                "http://mirror3.com/f".to_string(),
609            ],
610        );
611        let text = entry.serialize();
612        let uri_line = text.lines().next().unwrap();
613        assert_eq!(
614            uri_line.matches('\t').count(),
615            2,
616            "3 URIs should have 2 tab separators"
617        );
618    }
619
620    // ==================== New Field Tests (Session Persistence Enhancement) ====================
621
622    #[test]
623    fn test_serialize_new_fields() {
624        let mut entry = SessionEntry::new(1, vec!["http://example.com/file.bin".to_string()]);
625        entry.total_length = 1024 * 1024; // 1MB
626        entry.completed_length = 512 * 1024; // 512KB
627        entry.upload_length = 1024;
628        entry.download_speed = 2048;
629        entry.status = "active".to_string();
630        entry.error_code = None;
631
632        let text = entry.serialize();
633
634        // Verify new fields appear in output
635        assert!(
636            text.contains("TOTAL_LENGTH=1048576"),
637            "Should contain TOTAL_LENGTH"
638        );
639        assert!(
640            text.contains("COMPLETED_LENGTH=524288"),
641            "Should contain COMPLETED_LENGTH"
642        );
643        assert!(
644            text.contains("UPLOAD_LENGTH=1024"),
645            "Should contain UPLOAD_LENGTH"
646        );
647        assert!(
648            text.contains("DOWNLOAD_SPEED=2048"),
649            "Should contain DOWNLOAD_SPEED"
650        );
651        assert!(text.contains("STATUS=active"), "Should contain STATUS");
652    }
653
654    #[test]
655    fn test_deserialize_with_all_fields() {
656        let input = r#"http://example.com/bigfile.zip
657 GID=1
658 TOTAL_LENGTH=10485760
659 COMPLETED_LENGTH=5242880
660 UPLOAD_LENGTH=2048
661 DOWNLOAD_SPEED=4096
662 STATUS=active
663 ERROR_CODE=
664 BITFIELD=
665 NUM_PIECES=0
666 PIECE_LENGTH=0
667 INFO_HASH=
668 RESUME_OFFSET=5242880
669"#;
670
671        let entry = SessionEntry::deserialize_line(input).unwrap();
672
673        assert_eq!(entry.total_length, 10485760);
674        assert_eq!(entry.completed_length, 5242880);
675        assert_eq!(entry.upload_length, 2048);
676        assert_eq!(entry.download_speed, 4096);
677        assert_eq!(entry.status, "active");
678        assert_eq!(entry.error_code, None);
679        assert_eq!(entry.resume_offset, Some(5242880));
680    }
681
682    #[test]
683    fn test_deserialize_user_options() {
684        // User-defined options should be stored in options map
685        let input = r#"http://example.com/file.zip
686 GID=1
687 split=4
688 dir=/downloads
689 TOTAL_LENGTH=1000
690"#;
691
692        let entry = SessionEntry::deserialize_line(input).unwrap();
693
694        // Known fields parsed normally
695        assert_eq!(entry.total_length, 1000);
696
697        // User options stored in options
698        assert_eq!(entry.options.get("split").unwrap(), "4");
699        assert_eq!(entry.options.get("dir").unwrap(), "/downloads");
700    }
701
702    #[test]
703    fn test_bitfield_roundtrip() {
704        let mut entry =
705            SessionEntry::new(1, vec!["http://example.com/torrent.torrent".to_string()]);
706
707        // Set bitfield: [0xFF, 0xF0, 0x0F] - indicates some pieces completed
708        entry.bitfield = Some(vec![0xFF, 0xF0, 0x0F]);
709        entry.num_pieces = Some(24); // 3 bytes * 8 bits = 24 pieces
710        entry.piece_length = Some(262144); // 256KB
711
712        let text = entry.serialize();
713
714        // Verify hex encoding
715        assert!(
716            text.contains("BITFIELD=fff00f"),
717            "bitfield should be encoded as hex string"
718        );
719
720        // Deserialize verification
721        let restored = SessionEntry::deserialize_line(&text).unwrap();
722        assert_eq!(
723            restored.bitfield,
724            Some(vec![0xFF, 0xF0, 0x0F]),
725            "bitfield should be restored correctly"
726        );
727        assert_eq!(restored.num_pieces, Some(24));
728        assert_eq!(restored.piece_length, Some(262144));
729    }
730
731    #[test]
732    fn test_empty_bitfield_serialized_as_empty() {
733        let entry = SessionEntry::new(1, vec!["http://example.com/file.zip".to_string()]);
734        // bitfield defaults to None
735
736        let text = entry.serialize();
737
738        // None bitfield should produce empty value
739        assert!(
740            text.contains("BITFIELD=\n"),
741            "None bitfield should be serialized as empty value"
742        );
743
744        // Deserialize verification
745        let restored = SessionEntry::deserialize_line(&text).unwrap();
746        assert_eq!(
747            restored.bitfield, None,
748            "Empty bitfield should restore to None"
749        );
750    }
751
752    #[test]
753    fn test_default_session_entry_has_zero_progress() {
754        let entry = SessionEntry::new(99, vec!["http://test.com/f".to_string()]);
755
756        // Verify all new fields have correct defaults
757        assert_eq!(entry.total_length, 0);
758        assert_eq!(entry.completed_length, 0);
759        assert_eq!(entry.upload_length, 0);
760        assert_eq!(entry.download_speed, 0);
761        assert_eq!(entry.status, "active", "Default status should be 'active'");
762        assert_eq!(entry.error_code, None);
763        assert_eq!(entry.bitfield, None);
764        assert_eq!(entry.num_pieces, None);
765        assert_eq!(entry.piece_length, None);
766        assert_eq!(entry.info_hash_hex, None);
767        assert_eq!(entry.resume_offset, None);
768    }
769
770    #[test]
771    fn test_status_field_values() {
772        let statuses = ["active", "waiting", "paused", "error"];
773
774        for status in statuses {
775            let mut entry = SessionEntry::new(1, vec!["http://example.com/f".to_string()]);
776            entry.status = status.to_string();
777
778            let text = entry.serialize();
779            assert!(
780                text.contains(&format!("STATUS={}", status)),
781                "Status '{}' should be serialized correctly",
782                status
783            );
784
785            // Deserialize verification
786            let restored = SessionEntry::deserialize_line(&text).unwrap();
787            assert_eq!(
788                restored.status, status,
789                "Status '{}' should be deserialized correctly",
790                status
791            );
792        }
793    }
794
795    #[test]
796    fn test_resume_offset_for_http_ftp() {
797        let mut entry = SessionEntry::new(1, vec!["http://example.com/large-file.iso".to_string()]);
798
799        // Simulate HTTP download with partial data written
800        entry.total_length = 1073741824; // 1GB
801        entry.completed_length = 536870912; // 512MB completed
802        entry.resume_offset = Some(536870912); // Resume from 512MB
803        entry.status = "paused".to_string();
804
805        let text = entry.serialize();
806
807        // Verify resume offset is serialized correctly
808        assert!(
809            text.contains("RESUME_OFFSET=536870912"),
810            "resume offset should be serialized correctly"
811        );
812
813        // Deserialize and verify
814        let restored = SessionEntry::deserialize_line(&text).unwrap();
815        assert_eq!(
816            restored.resume_offset,
817            Some(536870912),
818            "resume offset should be restored correctly"
819        );
820        assert_eq!(restored.status, "paused");
821    }
822
823    #[test]
824    fn test_bt_specific_fields_only_when_present() {
825        // Test that BT-specific fields are truly optional
826        let mut entry = SessionEntry::new(1, vec!["magnet:?xt=urn:btih:abc123".to_string()]);
827
828        // Don't set any BT fields (keep them as None)
829        let text_without_bt = entry.serialize();
830        let restored_without_bt = SessionEntry::deserialize_line(&text_without_bt).unwrap();
831
832        assert_eq!(restored_without_bt.bitfield, None);
833        assert_eq!(restored_without_bt.num_pieces, None);
834        assert_eq!(restored_without_bt.piece_length, None);
835        assert_eq!(restored_without_bt.info_hash_hex, None);
836
837        // Now set BT fields
838        entry.bitfield = Some(vec![0xAA, 0xBB]);
839        entry.num_pieces = Some(16);
840        entry.piece_length = Some(524288);
841        entry.info_hash_hex = Some("abc123def456".to_string());
842
843        let text_with_bt = entry.serialize();
844        let restored_with_bt = SessionEntry::deserialize_line(&text_with_bt).unwrap();
845
846        assert_eq!(restored_with_bt.bitfield, Some(vec![0xAA, 0xBB]));
847        assert_eq!(restored_with_bt.num_pieces, Some(16));
848        assert_eq!(restored_with_bt.piece_length, Some(524288));
849        assert_eq!(
850            restored_with_bt.info_hash_hex,
851            Some("abc123def456".to_string())
852        );
853    }
854
855    #[test]
856    fn test_download_options_to_map_all_fields() {
857        // Verify that all non-default fields are serialized to the map
858        let opts = DownloadOptions {
859            split: Some(8),
860            max_connection_per_server: Some(4),
861            max_download_limit: Some(102400),
862            max_upload_limit: Some(51200),
863            dir: Some("/downloads".to_string()),
864            out: Some("file.bin".to_string()),
865            seed_time: Some(3600),
866            seed_ratio: Some(2.0),
867            // File allocation
868            file_allocation: Some("trunc".to_string()),
869            mmap_threshold: Some(128 * 1024 * 1024),
870            secure_falloc: true,
871            // Checksum
872            checksum: Some(("sha256".to_string(), "abc123".to_string())),
873            // Cookies
874            cookie_file: Some("/tmp/cookies.txt".to_string()),
875            cookies: Some("key=value".to_string()),
876            // BT
877            bt_force_encrypt: true,
878            bt_require_crypto: true,
879            enable_dht: false,
880            dht_listen_port: Some(6881),
881            dht_entry_point: Some(vec!["router.bittorrent.com:6881".to_string()]),
882            enable_public_trackers: false,
883            bt_piece_selection_strategy: "sequential".to_string(),
884            bt_endgame_threshold: 10,
885            bt_max_upload_slots: Some(4),
886            bt_optimistic_unchoke_interval: Some(30),
887            bt_snubbed_timeout: Some(60),
888            bt_prioritize_piece: "head".to_string(),
889            enable_utp: true,
890            utp_listen_port: Some(6882),
891            // Retry
892            max_retries: 5,
893            retry_wait: 3,
894            // DHT file
895            dht_file_path: Some("/tmp/dht.dat".to_string()),
896            // Proxy
897            http_proxy: Some("http://proxy:8080".to_string()),
898            all_proxy: Some("socks5://proxy:1080".to_string()),
899            https_proxy: Some("http://proxy:8443".to_string()),
900            ftp_proxy: Some("http://proxy:8021".to_string()),
901            no_proxy: Some("localhost,127.0.0.1".to_string()),
902            // HTTP headers
903            header: vec!["X-Custom: foo".to_string(), "X-Other: bar".to_string()],
904            user_agent: Some("aria2-rust/1.0".to_string()),
905            referer: Some("http://example.com".to_string()),
906        };
907
908        let map = download_options_to_map(&opts);
909
910        // File allocation
911        assert_eq!(map.get("file-allocation").unwrap(), "trunc");
912        assert_eq!(map.get("mmap-threshold").unwrap(), "134217728");
913        assert_eq!(map.get("secure-falloc").unwrap(), "true");
914
915        // Checksum
916        assert_eq!(map.get("checksum").unwrap(), "sha256=abc123");
917
918        // Cookies
919        assert_eq!(map.get("cookie-file").unwrap(), "/tmp/cookies.txt");
920        assert_eq!(map.get("cookies").unwrap(), "key=value");
921
922        // BT
923        assert_eq!(map.get("bt-force-encrypt").unwrap(), "true");
924        assert_eq!(map.get("bt-require-crypto").unwrap(), "true");
925        assert_eq!(map.get("enable-dht").unwrap(), "false");
926        assert_eq!(map.get("dht-listen-port").unwrap(), "6881");
927        assert_eq!(
928            map.get("dht-entry-point").unwrap(),
929            "router.bittorrent.com:6881"
930        );
931        assert_eq!(map.get("enable-public-trackers").unwrap(), "false");
932        assert_eq!(
933            map.get("bt-piece-selection-strategy").unwrap(),
934            "sequential"
935        );
936        assert_eq!(map.get("bt-endgame-threshold").unwrap(), "10");
937        assert_eq!(map.get("bt-max-upload-slots").unwrap(), "4");
938        assert_eq!(map.get("bt-optimistic-unchoke-interval").unwrap(), "30");
939        assert_eq!(map.get("bt-snubbed-timeout").unwrap(), "60");
940        assert_eq!(map.get("bt-prioritize-piece").unwrap(), "head");
941        assert_eq!(map.get("enable-utp").unwrap(), "true");
942        assert_eq!(map.get("utp-listen-port").unwrap(), "6882");
943
944        // Retry
945        assert_eq!(map.get("max-retries").unwrap(), "5");
946        assert_eq!(map.get("retry-wait").unwrap(), "3");
947
948        // DHT file
949        assert_eq!(map.get("dht-file-path").unwrap(), "/tmp/dht.dat");
950
951        // Proxy
952        assert_eq!(map.get("http-proxy").unwrap(), "http://proxy:8080");
953        assert_eq!(map.get("all-proxy").unwrap(), "socks5://proxy:1080");
954        assert_eq!(map.get("https-proxy").unwrap(), "http://proxy:8443");
955        assert_eq!(map.get("ftp-proxy").unwrap(), "http://proxy:8021");
956        assert_eq!(map.get("no-proxy").unwrap(), "localhost,127.0.0.1");
957
958        // HTTP headers
959        assert_eq!(map.get("header").unwrap(), "X-Custom: foo,X-Other: bar");
960        assert_eq!(map.get("user-agent").unwrap(), "aria2-rust/1.0");
961        assert_eq!(map.get("referer").unwrap(), "http://example.com");
962    }
963
964    #[test]
965    fn test_download_options_to_map_defaults_excluded() {
966        // Default DownloadOptions should produce a minimal map.
967        // enable_dht and enable_public_trackers default to true (matching the
968        // load path's `unwrap_or(true)`), so they are NOT saved when at the
969        // default value — the save logic only serializes them when disabled.
970        let opts = DownloadOptions::default();
971        let map = download_options_to_map(&opts);
972
973        // secure_falloc defaults to false -> should NOT be in map
974        assert!(!map.contains_key("secure-falloc"));
975        // file_allocation defaults to None -> should NOT be in map
976        assert!(!map.contains_key("file-allocation"));
977        // enable_dht and enable_public_trackers default to true -> NOT saved
978        assert!(!map.contains_key("enable-dht"));
979        assert!(!map.contains_key("enable-public-trackers"));
980    }
981}