Skip to main content

aria2_core/session/
session_persistence.rs

1//! Session Save/Load Persistence - Phase 15 H4
2//!
3//! Provides complete session state persistence using the ResumeData JSON format
4//! (.aria2 files). This module bridges the ActiveSessionManager with the
5//! ResumeData serialization system for cross-restart download resumption.
6//!
7//! # Architecture
8//!
9//! ```text
10//! session_persistence.rs (this file)
11//!   ├── SessionPersistence struct - High-level save/load coordinator
12//!   ├── save_state() - Serialize all commands to .aria2 files
13//!   ├── load_state() - Restore commands from .aria2 files
14//!   ├── start_auto_save() - Background periodic save task
15//!   └── signal_handler() - SIGTERM/SIGINT graceful shutdown
16//!
17//! Dependencies:
18//!   resume_data.rs - ResumeData, UriState, ChecksumInfo structs
19//!   active_session.rs - ActiveSessionManager for session file I/O
20//! ```
21
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use std::time::Duration;
25
26use serde::{Deserialize, Serialize};
27use tokio::sync::RwLock;
28use tracing::{debug, info, warn};
29
30use crate::engine::resume_data::{ResumeData, ResumeDataExt};
31use crate::http::cookie_storage::CookieJar;
32use crate::request::request_group::{DownloadOptions, DownloadStatus, GroupId, RequestGroup};
33use crate::selector::server_stat_man::ServerStatMan;
34
35/// Filename for global session options saved alongside .aria2 files
36const SESSION_OPTIONS_FILENAME: &str = "session_options.json";
37
38/// Default auto-save interval in seconds
39const DEFAULT_AUTO_SAVE_INTERVAL_SECS: u64 = 60;
40
41/// High-level session persistence manager
42///
43/// Coordinates saving and loading of download session state using the
44/// ResumeData JSON format. Manages both individual command states (.aria2
45/// files) and global session options.
46///
47/// # Examples
48///
49/// ```ignore
50/// use aria2_core::session::session_persistence::SessionPersistence;
51/// use std::path::Path;
52///
53/// let session = SessionPersistence::new(Path::new("/tmp/aria2_session"));
54///
55/// // Save current state
56/// let count = session.save_state(&groups).await?;
57/// println!("Saved {} downloads", count);
58///
59/// // Load saved state
60/// let count = session.load_state(&mut groups).await?;
61/// println!("Restored {} downloads", count);
62/// ```
63pub struct SessionPersistence {
64    /// Directory where .aria2 files are stored
65    session_dir: PathBuf,
66    /// Auto-save interval
67    auto_save_interval: Duration,
68    /// Whether auto-save is enabled
69    auto_save_enabled: bool,
70    /// Optional cookie jar for persisting cookies alongside session data
71    cookie_jar: Option<CookieJar>,
72}
73
74impl SessionPersistence {
75    /// Create a new SessionPersistence instance
76    ///
77    /// # Arguments
78    ///
79    /// * `session_dir` - Directory path for storing .aria2 session files
80    pub fn new(session_dir: &Path) -> Self {
81        Self {
82            session_dir: session_dir.to_path_buf(),
83            auto_save_interval: Duration::from_secs(DEFAULT_AUTO_SAVE_INTERVAL_SECS),
84            auto_save_enabled: true,
85            cookie_jar: None,
86        }
87    }
88
89    /// Create with custom auto-save interval
90    pub fn with_interval(mut self, interval_secs: u64) -> Self {
91        self.auto_save_interval = Duration::from_secs(interval_secs.max(10));
92        self
93    }
94
95    /// Disable auto-save (only manual save/load)
96    pub fn without_auto_save(mut self) -> Self {
97        self.auto_save_enabled = false;
98        self
99    }
100
101    /// Set cookie jar for persistence alongside session data
102    pub fn with_cookie_jar(mut self, jar: CookieJar) -> Self {
103        self.cookie_jar = Some(jar);
104        self
105    }
106
107    /// Get mutable reference to the cookie jar for adding cookies before saving
108    pub fn cookie_jar_mut(&mut self) -> Option<&mut CookieJar> {
109        self.cookie_jar.as_mut()
110    }
111
112    /// Get reference to the cookie jar
113    pub fn cookie_jar(&self) -> Option<&CookieJar> {
114        self.cookie_jar.as_ref()
115    }
116
117    /// Get the session directory path
118    pub fn session_dir(&self) -> &Path {
119        &self.session_dir
120    }
121
122    /// Save all active/paused/stopped command states to the session directory
123    ///
124    /// Iterates through all RequestGroups, converts each to ResumeData,
125    /// and writes individual .aria2 files. Also saves global options.
126    ///
127    /// # Arguments
128    ///
129    /// * `groups` - Slice of active download groups to persist
130    ///
131    /// # Returns
132    ///
133    /// * `Ok(usize)` - Number of successfully saved commands
134    /// * `Err(String)` - Error message if critical failure occurs
135    ///
136    /// # File Format
137    ///
138    /// Each command is saved as `{gid}.aria2` in JSON format.
139    /// Global options are saved as `session_options.json`.
140    pub async fn save_state(&self, groups: &[Arc<RwLock<RequestGroup>>]) -> Result<usize, String> {
141        // Ensure session directory exists
142        tokio::fs::create_dir_all(&self.session_dir)
143            .await
144            .map_err(|e| {
145                format!(
146                    "Failed to create session dir {}: {}",
147                    self.session_dir.display(),
148                    e
149                )
150            })?;
151
152        let mut saved = 0usize;
153
154        for group_lock in groups.iter() {
155            let group = group_lock.read().await;
156
157            // Convert RequestGroup to ResumeData
158            match ResumeData::from_request_group(&group).await {
159                Ok(resume_data) => {
160                    let file_name = format!("{}.aria2", resume_data.gid);
161                    let path = self.session_dir.join(&file_name);
162
163                    if let Err(e) = resume_data.save_to_file(&path) {
164                        warn!(
165                            gid = %resume_data.gid,
166                            error = %e,
167                            "Failed to save resume data for GID"
168                        );
169                        continue;
170                    }
171                    saved += 1;
172                    debug!(
173                        gid = %resume_data.gid,
174                        path = %path.display(),
175                        "Saved resume data"
176                    );
177                }
178                Err(e) => {
179                    debug!(
180                        gid = %group.gid().value(),
181                        error = %e,
182                        "Skipping command that cannot be serialized"
183                    );
184                }
185            }
186        }
187
188        // Save global options summary
189        self.save_global_options(groups).await?;
190
191        // Persist cookies if cookie jar is available
192        if let Some(ref jar) = self.cookie_jar {
193            let cookie_path = self.session_dir.join("cookies.json");
194            if let Err(e) = Self::save_cookie_jar_to_file(jar, &cookie_path).await {
195                warn!("Failed to persist cookies: {}", e);
196            } else {
197                debug!(path = %cookie_path.display(), "Cookies persisted to session");
198            }
199        }
200
201        info!(
202            saved,
203            dir = %self.session_dir.display(),
204            "Session state saved"
205        );
206
207        Ok(saved)
208    }
209
210    /// Load saved states from session directory and restore paused commands
211    ///
212    /// Reads all .aria2 files from the session directory, deserializes them,
213    /// and creates paused download commands for each valid entry.
214    ///
215    /// # Arguments
216    ///
217    /// * `groups` - Mutable reference to the groups vector to restore into
218    ///
219    /// # Returns
220    ///
221    /// * `Ok(usize)` - Number of successfully restored commands
222    /// * `Err(String)` - Error message if critical failure occurs
223    ///
224    /// # Graceful Error Handling
225    ///
226    /// - Missing session directory returns Ok(0) (not an error)
227    /// - Corrupt/malformed .aria2 files are skipped with a warning
228    /// - Partial restoration is allowed (some files may fail)
229    pub async fn load_state(
230        &mut self,
231        groups: &mut Vec<Arc<RwLock<RequestGroup>>>,
232    ) -> Result<usize, String> {
233        if !self.session_dir.exists() {
234            debug!(
235                dir = %self.session_dir.display(),
236                "Session directory does not exist, nothing to load"
237            );
238            return Ok(0);
239        }
240
241        let mut loaded = 0usize;
242        let mut entries = tokio::fs::read_dir(&self.session_dir).await.map_err(|e| {
243            format!(
244                "Failed to read session dir {}: {}",
245                self.session_dir.display(),
246                e
247            )
248        })?;
249
250        while let Ok(Some(entry)) = entries.next_entry().await {
251            let path = entry.path();
252
253            // Only process .aria2 files
254            let is_aria2 = path.extension().map(|e| e == "aria2").unwrap_or(false);
255
256            if !is_aria2 {
257                continue;
258            }
259
260            match ResumeData::load_from_file(&path) {
261                Ok(Some(resume_data)) => {
262                    // Restore command from resume data
263                    match Self::restore_command(&resume_data) {
264                        Ok(group) => {
265                            groups.push(Arc::new(RwLock::new(group)));
266                            loaded += 1;
267                            info!(
268                                gid = %resume_data.gid,
269                                status = %resume_data.status,
270                                "Restored download from session"
271                            );
272                        }
273                        Err(e) => {
274                            warn!(
275                                gid = %resume_data.gid,
276                                error = %e,
277                                "Failed to restore command from resume data"
278                            );
279                        }
280                    }
281                }
282                Ok(None) => {
283                    debug!(path = %path.display(), "Resume file was empty (skipped)");
284                }
285                Err(e) => {
286                    warn!(
287                        path = %path.display(),
288                        error = %e,
289                        "Corrupted or invalid .aria2 file, skipping gracefully"
290                    );
291                    // Continue loading other files - don't abort entire load
292                }
293            }
294        }
295
296        // Load global options if available
297        let _ = self.load_global_options().await;
298
299        // Load cookies from session directory
300        let cookie_path = self.session_dir.join("cookies.json");
301        if cookie_path.exists() {
302            match Self::load_cookie_jar_from_file(&cookie_path).await {
303                Ok(jar) => {
304                    self.cookie_jar = Some(jar);
305                    info!("Loaded cookies from session");
306                }
307                Err(e) => {
308                    warn!("Failed to load cookies from session: {}", e);
309                }
310            }
311        }
312
313        info!(
314            loaded,
315            dir = %self.session_dir.display(),
316            "Session state loaded"
317        );
318
319        Ok(loaded)
320    }
321
322    /// Restore a single download command from ResumeData
323    ///
324    /// Creates a new paused RequestGroup with the URIs and options
325    /// extracted from the persisted state.
326    fn restore_command(resume_data: &ResumeData) -> Result<RequestGroup, String> {
327        if resume_data.uris.is_empty() {
328            return Err("ResumeData has no URIs, cannot restore".to_string());
329        }
330
331        // Extract URIs from UriState list
332        let uris: Vec<String> = resume_data.uris.iter().map(|u| u.uri.clone()).collect();
333
334        // Build DownloadOptions from stored state
335        let mut options = DownloadOptions::default();
336
337        // Set output path if available
338        if let Some(ref output_path) = resume_data.output_path {
339            if let Some(parent) = Path::new(output_path).parent() {
340                options.dir = Some(parent.to_string_lossy().to_string());
341            }
342            if let Some(file_name) = Path::new(output_path).file_name() {
343                options.out = Some(file_name.to_string_lossy().to_string());
344            }
345        }
346
347        // Generate GID from stored value (try to parse hex, or create new)
348        let gid = if !resume_data.gid.is_empty() {
349            GroupId::from_hex_string(&resume_data.gid).unwrap_or_else(GroupId::new_random)
350        } else {
351            GroupId::new_random()
352        };
353
354        let group = RequestGroup::new(gid, uris, options);
355
356        // Mark as paused if status indicates so
357        if resume_data.status == "paused" || resume_data.status == "waiting" {
358            // The group will be created in a paused/waiting state
359            // Actual pause handling depends on the engine's lifecycle management
360        }
361
362        // Restore progress information if available
363        if resume_data.completed_length > 0 {
364            group.set_resume_offset(resume_data.completed_length);
365        }
366
367        Ok(group)
368    }
369
370    /// Start background auto-save task
371    ///
372    /// Spawns a Tokio task that periodically calls save_state().
373    /// The task runs until the returned handle is dropped or cancelled.
374    ///
375    /// # Arguments
376    ///
377    /// * `groups` - Arc-wrapped shared reference to the groups vector
378    ///
379    /// # Returns
380    ///
381    /// A JoinHandle that can be used to cancel the auto-save task
382    pub fn start_auto_save(
383        &self,
384        groups: Arc<RwLock<Vec<Arc<RwLock<RequestGroup>>>>>,
385    ) -> Option<tokio::task::JoinHandle<()>> {
386        if !self.auto_save_enabled {
387            debug!("Auto-save is disabled");
388            return None;
389        }
390
391        let session_dir = self.session_dir.clone();
392        let interval = self.auto_save_interval;
393
394        info!(
395            interval_secs = interval.as_secs(),
396            dir = %session_dir.display(),
397            "Starting auto-save task"
398        );
399
400        Some(tokio::spawn(async move {
401            let mut ticker = tokio::time::interval(interval);
402
403            loop {
404                ticker.tick().await;
405
406                let groups_read = groups.read().await;
407                let persistence = SessionPersistence::new(&session_dir).without_auto_save();
408
409                match persistence.save_state(&groups_read).await {
410                    Ok(count) => {
411                        if count > 0 {
412                            debug!(count, "Auto-save completed successfully");
413                        }
414                    }
415                    Err(e) => {
416                        warn!(error = %e, "Auto-save failed, will retry next interval");
417                    }
418                }
419            }
420        }))
421    }
422
423    /// Save global options summary to session directory
424    async fn save_global_options(
425        &self,
426        _groups: &[Arc<RwLock<RequestGroup>>],
427    ) -> Result<(), String> {
428        let opts_path = self.session_dir.join(SESSION_OPTIONS_FILENAME);
429
430        // Build a simple options summary from all groups
431        let options_summary = serde_json::json!({
432            "version": "1.0",
433            "saved_at": chrono_timestamp_or_fallback(),
434            "note": "Global session options summary"
435        });
436
437        let json = serde_json::to_string_pretty(&options_summary)
438            .map_err(|e| format!("Failed to serialize session options: {}", e))?;
439
440        tokio::fs::write(&opts_path, json).await.map_err(|e| {
441            format!(
442                "Failed to write session options {}: {}",
443                opts_path.display(),
444                e
445            )
446        })?;
447
448        Ok(())
449    }
450
451    /// Load global options from session directory
452    async fn load_global_options(&self) -> Result<(), String> {
453        let opts_path = self.session_dir.join(SESSION_OPTIONS_FILENAME);
454
455        if !opts_path.exists() {
456            return Ok(());
457        }
458
459        let content = tokio::fs::read_to_string(&opts_path).await.map_err(|e| {
460            format!(
461                "Failed to read session options {}: {}",
462                opts_path.display(),
463                e
464            )
465        })?;
466
467        // Validate it's valid JSON (basic sanity check)
468        let _parsed: serde_json::Value = serde_json::from_str(&content)
469            .map_err(|e| format!("Invalid JSON in session options: {}", e))?;
470
471        debug!(path = %opts_path.display(), "Loaded session options");
472
473        Ok(())
474    }
475
476    /// Clean up all session files (for testing or reset)
477    pub async fn cleanup(&self) -> Result<(), String> {
478        if !self.session_dir.exists() {
479            return Ok(());
480        }
481
482        let mut entries = tokio::fs::read_dir(&self.session_dir)
483            .await
484            .map_err(|e| format!("Failed to read session dir: {}", e))?;
485
486        while let Ok(Some(entry)) = entries.next_entry().await {
487            let path = entry.path();
488            if let Err(e) = tokio::fs::remove_file(&path).await {
489                warn!(path = %path.display(), error = %e, "Failed to remove session file");
490            }
491        }
492
493        info!(dir = %self.session_dir.display(), "Session directory cleaned up");
494        Ok(())
495    }
496
497    // =====================================================================
498    // Server Statistics Persistence
499    // =====================================================================
500
501    /// Save server statistics to the session directory.
502    ///
503    /// Persists all server performance statistics (download speeds, error counts,
504    /// etc.) to a JSON file in the session directory. This allows the adaptive
505    /// URI selector to remember server performance across restarts.
506    ///
507    /// # Arguments
508    ///
509    /// * `stat_man` - Reference to the ServerStatMan to save
510    ///
511    /// # Returns
512    ///
513    /// * `Ok(usize)` - Number of server stats saved
514    /// * `Err(String)` - Error message if save fails
515    ///
516    /// # File Location
517    ///
518    /// Stats are saved to `{session_dir}/server-stat.json`
519    ///
520    /// # Example
521    ///
522    /// ```ignore
523    /// use aria2_core::session::session_persistence::SessionPersistence;
524    /// use aria2_core::selector::server_stat_man::ServerStatMan;
525    ///
526    /// let persistence = SessionPersistence::new(Path::new("/tmp/aria2_session"));
527    /// let stat_man = ServerStatMan::new();
528    /// stat_man.update("fast.mirror.com", 10000, false);
529    ///
530    /// let saved = persistence.save_server_stats(&stat_man).await?;
531    /// println!("Saved {} server stats", saved);
532    /// ```
533    pub async fn save_server_stats(&self, stat_man: &ServerStatMan) -> Result<usize, String> {
534        let stat_file = self.session_dir.join("server-stat.json");
535        let saved = stat_man.save_to_file_async(&stat_file).await?;
536
537        if saved > 0 {
538            debug!(
539                count = saved,
540                path = %stat_file.display(),
541                "Server statistics saved"
542            );
543        }
544
545        Ok(saved)
546    }
547
548    /// Load server statistics from the session directory.
549    ///
550    /// Restores previously saved server performance statistics from a JSON file
551    /// in the session directory. This allows the adaptive URI selector to
552    /// make informed decisions immediately after startup.
553    ///
554    /// # Arguments
555    ///
556    /// * `stat_man` - Reference to the ServerStatMan to load into
557    ///
558    /// # Returns
559    ///
560    /// * `Ok(usize)` - Number of server stats loaded
561    /// * `Err(String)` - Error message if load fails
562    ///
563    /// # Behavior
564    ///
565    /// - Returns `Ok(0)` if no server-stat.json file exists (not an error)
566    /// - Returns error if file exists but is invalid
567    /// - Merges with existing stats (doesn't clear current stats)
568    ///
569    /// # Example
570    ///
571    /// ```ignore
572    /// use aria2_core::session::session_persistence::SessionPersistence;
573    /// use aria2_core::selector::server_stat_man::ServerStatMan;
574    ///
575    /// let persistence = SessionPersistence::new(Path::new("/tmp/aria2_session"));
576    /// let stat_man = ServerStatMan::new();
577    ///
578    /// let loaded = persistence.load_server_stats(&stat_man).await?;
579    /// println!("Loaded {} server stats from previous session", loaded);
580    /// ```
581    pub async fn load_server_stats(&self, stat_man: &ServerStatMan) -> Result<usize, String> {
582        let stat_file = self.session_dir.join("server-stat.json");
583
584        if !stat_file.exists() {
585            debug!("No server statistics file found, starting fresh");
586            return Ok(0);
587        }
588
589        let loaded = stat_man.load_from_file_async(&stat_file).await?;
590
591        if loaded > 0 {
592            info!(
593                count = loaded,
594                path = %stat_file.display(),
595                "Server statistics loaded from previous session"
596            );
597        }
598
599        Ok(loaded)
600    }
601
602    // =====================================================================
603    // K2.1 — Selective Save Methods
604    // =====================================================================
605
606    /// Save only active/in-progress downloads (skip completed/stopped/error).
607    ///
608    /// Filters groups by download status, persisting only those that are
609    /// actively downloading or waiting in queue.
610    ///
611    /// # Arguments
612    ///
613    /// * `groups` - Slice of all download groups to filter
614    ///
615    /// # Returns
616    ///
617    /// * `Ok(usize)` - Number of active downloads successfully saved
618    /// * `Err(String)` - Error message if critical failure occurs
619    pub async fn save_active_only(
620        &self,
621        groups: &[Arc<RwLock<RequestGroup>>],
622    ) -> Result<usize, String> {
623        let mut count = 0;
624        for group in groups {
625            let g = group.read().await;
626            let status = g.status().await;
627
628            // Only save if actively downloading or waiting
629            match status {
630                DownloadStatus::Active | DownloadStatus::Waiting => {
631                    drop(g);
632                    // Convert and save this single group
633                    let group_read = group.read().await;
634                    match ResumeData::from_request_group(&group_read).await {
635                        Ok(resume_data) => {
636                            drop(group_read);
637                            let file_name = format!("{}.aria2", resume_data.gid);
638                            let path = self.session_dir.join(&file_name);
639                            if resume_data.save_to_file(&path).is_ok() {
640                                count += 1;
641                                debug!(gid = %resume_data.gid, "Saved active download");
642                            } else {
643                                warn!(gid = %resume_data.gid, "Failed to save active download");
644                            }
645                        }
646                        Err(e) => {
647                            debug!(error = %e, "Skipping active download that cannot be serialized");
648                        }
649                    }
650                }
651                _ => {} // Skip completed, paused, removed, error
652            }
653        }
654        debug!(
655            saved = count,
656            total = groups.len(),
657            "save_active_only completed"
658        );
659        Ok(count)
660    }
661
662    /// Save only completed downloads for archival.
663    ///
664    /// Filters groups by completion status, persisting only finished downloads.
665    /// Useful for creating archives of successful downloads separate from
666    /// active/pending work.
667    ///
668    /// # Arguments
669    ///
670    /// * `groups` - Slice of all download groups to filter
671    ///
672    /// # Returns
673    ///
674    /// * `Ok(usize)` - Number of completed downloads successfully saved
675    /// * `Err(String)` - Error message if critical failure occurs
676    pub async fn save_completed(
677        &self,
678        groups: &[Arc<RwLock<RequestGroup>>],
679    ) -> Result<usize, String> {
680        let mut count = 0;
681        for group in groups {
682            let g = group.read().await;
683            let status = g.status().await;
684
685            if status.is_completed() || matches!(status, DownloadStatus::Complete) {
686                drop(g);
687                // Convert and save this completed group
688                let group_read = group.read().await;
689                match ResumeData::from_request_group(&group_read).await {
690                    Ok(resume_data) => {
691                        drop(group_read);
692                        let file_name = format!("{}.aria2", resume_data.gid);
693                        let path = self.session_dir.join(&file_name);
694                        if resume_data.save_to_file(&path).is_ok() {
695                            count += 1;
696                            debug!(gid = %resume_data.gid, "Saved completed download");
697                        }
698                    }
699                    Err(e) => {
700                        debug!(error = %e, "Skipping completed download that cannot be serialized");
701                    }
702                }
703            }
704        }
705        debug!(
706            saved = count,
707            total = groups.len(),
708            "save_completed completed"
709        );
710        Ok(count)
711    }
712
713    // =====================================================================
714    // K2.3 — Cookie Persistence Helpers
715    // =====================================================================
716
717    /// Save cookie jar to a JSON file for persistence.
718    ///
719    /// Serializes all cookies in the jar to JSON format for storage alongside
720    /// session data. Uses simple JSON serialization since CookieJar doesn't
721    /// have built-in file I/O methods.
722    async fn save_cookie_jar_to_file(jar: &CookieJar, path: &Path) -> Result<(), String> {
723        // Use serde_json to serialize the cookie jar's internal data
724        #[derive(Serialize)]
725        struct SerializableJar<'a> {
726            cookies: &'a [crate::http::cookie_storage::JarCookie],
727        }
728
729        let serializable = SerializableJar {
730            cookies: &jar.cookies,
731        };
732
733        let json = serde_json::to_string_pretty(&serializable).map_err(|e| e.to_string())?;
734
735        tokio::fs::write(path, json)
736            .await
737            .map_err(|e| format!("Failed to write cookie file: {}", e))
738    }
739
740    /// Load cookie jar from a JSON file.
741    ///
742    /// Deserializes cookies from JSON format and creates a new CookieJar
743    /// instance with the loaded data.
744    async fn load_cookie_jar_from_file(path: &Path) -> Result<CookieJar, String> {
745        let content = tokio::fs::read_to_string(path)
746            .await
747            .map_err(|e| format!("Failed to read cookie file: {}", e))?;
748
749        #[derive(Deserialize)]
750        struct SerializableJar {
751            cookies: Vec<crate::http::cookie_storage::JarCookie>,
752        }
753
754        let parsed: SerializableJar =
755            serde_json::from_str(&content).map_err(|e| format!("Invalid cookie JSON: {}", e))?;
756
757        let mut jar = CookieJar::new();
758        for cookie in parsed.cookies {
759            jar.store(cookie);
760        }
761
762        Ok(jar)
763    }
764}
765
766/// Fallback timestamp generator when chrono is not available
767fn chrono_timestamp_or_fallback() -> String {
768    use std::time::{SystemTime, UNIX_EPOCH};
769    SystemTime::now()
770        .duration_since(UNIX_EPOCH)
771        .map(|d| d.as_secs().to_string())
772        .unwrap_or_else(|_| "unknown".to_string())
773}
774
775// =========================================================================
776// K2.2 — DHT State Snapshot
777// =========================================================================
778
779/// Snapshot of DHT (Distributed Hash Table) routing state for persistence.
780///
781/// Captures the current state of DHT nodes, token secret, and bootstrap timing
782/// to allow quick resumption without full bootstrap on restart.
783///
784/// # Serialization
785///
786/// This struct implements Serialize/Deserialize for JSON persistence alongside
787/// session data. Use `to_json_string()` and `from_json_string()` for conversion.
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct DhtStateSnapshot {
790    /// Known DHT nodes in the routing table
791    pub nodes: Vec<DhtNodeInfo>,
792    /// Current token secret used for DHT get_peers requests (20 bytes)
793    pub token_secret: [u8; 20],
794    /// Unix epoch timestamp of last successful bootstrap, if any
795    pub last_bootstrap_epoch_secs: Option<u64>,
796    /// Total number of nodes in the snapshot (convenience field)
797    pub total_nodes: usize,
798}
799
800/// Information about a single DHT node in the routing table.
801#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct DhtNodeInfo {
803    /// 20-byte node ID (SHA-1 hash)
804    pub id: [u8; 20],
805    /// Network address as "ip:port" string
806    pub addr: String,
807    /// Unix epoch timestamp when this node was last seen/verified
808    pub last_seen_epoch_secs: u64,
809}
810
811impl DhtStateSnapshot {
812    /// Create an empty snapshot (for when DHT is unavailable or not initialized).
813    ///
814    /// Returns a snapshot with no nodes and zeroed token secret,
815    /// suitable as a default or placeholder value.
816    pub fn empty() -> Self {
817        Self {
818            nodes: vec![],
819            token_secret: [0u8; 20],
820            last_bootstrap_epoch_secs: None,
821            total_nodes: 0,
822        }
823    }
824
825    /// Create a snapshot from node data with automatic total_nodes calculation.
826    ///
827    /// # Arguments
828    ///
829    /// * `nodes` - Vector of known DHT nodes
830    /// * `token_secret` - Current 20-byte token secret
831    /// * `last_bootstrap` - Optional timestamp of last bootstrap
832    pub fn new(
833        nodes: Vec<DhtNodeInfo>,
834        token_secret: [u8; 20],
835        last_bootstrap_epoch_secs: Option<u64>,
836    ) -> Self {
837        let total_nodes = nodes.len();
838        Self {
839            nodes,
840            token_secret,
841            last_bootstrap_epoch_secs,
842            total_nodes,
843        }
844    }
845
846    /// Serialize snapshot to JSON string for persistence.
847    ///
848    /// # Returns
849    ///
850    /// * `Ok(String)` - JSON-formatted snapshot data
851    /// * `Err(String)` - Error message if serialization fails
852    pub fn to_json_string(&self) -> Result<String, String> {
853        serde_json::to_string(self).map_err(|e| e.to_string())
854    }
855
856    /// Parse snapshot from JSON string.
857    ///
858    /// # Arguments
859    ///
860    /// * `json` - JSON string containing serialized snapshot data
861    ///
862    /// # Returns
863    ///
864    /// * `Ok(DhtStateSnapshot)` - Deserialized snapshot
865    /// * `Err(String)` - Error message if parsing fails
866    pub fn from_json_string(json: &str) -> Result<Self, String> {
867        serde_json::from_str(json).map_err(|e| e.to_string())
868    }
869}
870
871// =========================================================================
872// Tests
873// =========================================================================
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    use std::fs;
879
880    /// Helper to create a temporary directory for tests
881    fn create_test_session_dir() -> PathBuf {
882        let ts = std::time::SystemTime::now()
883            .duration_since(std::time::UNIX_EPOCH)
884            .unwrap_or_default()
885            .as_nanos()
886            % 1_000_000_000;
887        let dir =
888            std::env::temp_dir().join(format!("aria2_session_test_{}_{}", std::process::id(), ts));
889        let _ = fs::remove_dir_all(&dir);
890        fs::create_dir_all(&dir).expect("Failed to create test session directory");
891        dir
892    }
893
894    /// Helper to create test RequestGroups
895    fn create_test_groups(count: usize) -> Vec<Arc<RwLock<RequestGroup>>> {
896        let mut groups = Vec::new();
897        for i in 0..count {
898            let gid = GroupId::new(i as u64 + 1000);
899            let uri = format!("http://example.com/file{}.bin", i);
900            let options = DownloadOptions {
901                dir: Some("/downloads".to_string()),
902                split: Some(4),
903                ..Default::default()
904            };
905            let group = Arc::new(RwLock::new(RequestGroup::new(gid, vec![uri], options)));
906            groups.push(group);
907        }
908        groups
909    }
910
911    #[tokio::test]
912    async fn test_session_save_creates_files() {
913        let session_dir = create_test_session_dir();
914        let persistence = SessionPersistence::new(&session_dir);
915
916        let groups = create_test_groups(3);
917
918        // Save state
919        let saved_count = persistence
920            .save_state(&groups)
921            .await
922            .expect("Save should succeed");
923
924        assert_eq!(saved_count, 3, "Should save 3 commands");
925
926        // Verify .aria2 files were created
927        let entries: Vec<_> = fs::read_dir(&session_dir)
928            .expect("Should read session dir")
929            .filter_map(|e| e.ok())
930            .collect();
931
932        // Should have at least 3 .aria2 files + 1 options file
933        let aria2_count = entries
934            .iter()
935            .filter(|e| {
936                e.path()
937                    .extension()
938                    .map(|ext| ext == "aria2")
939                    .unwrap_or(false)
940            })
941            .count();
942
943        assert_eq!(aria2_count, 3, "Should have 3 .aria2 files");
944
945        // Verify each file contains valid JSON with GID
946        for entry in entries.iter().filter(|e| {
947            e.path()
948                .extension()
949                .map(|ext| ext == "aria2")
950                .unwrap_or(false)
951        }) {
952            let content = fs::read_to_string(entry.path()).expect("Should read file");
953            let parsed: serde_json::Value =
954                serde_json::from_str(&content).expect("Should be valid JSON");
955            assert!(
956                parsed.get("gid").is_some(),
957                "Each .aria2 file should contain a GID field"
958            );
959        }
960
961        // Clean up
962        let _ = fs::remove_dir_all(&session_dir);
963    }
964
965    #[tokio::test]
966    async fn test_session_load_restores_commands() {
967        let session_dir = create_test_session_dir();
968        let mut persistence = SessionPersistence::new(&session_dir);
969
970        // Create and save original groups
971        let original_groups = create_test_groups(2);
972        let saved = persistence
973            .save_state(&original_groups)
974            .await
975            .expect("Save should succeed");
976        assert_eq!(saved, 2, "Should save 2 commands");
977
978        // Load into empty groups vector
979        let mut loaded_groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
980        let loaded = persistence
981            .load_state(&mut loaded_groups)
982            .await
983            .expect("Load should succeed");
984
985        assert_eq!(loaded, 2, "Should restore 2 commands");
986
987        // Verify restored groups have URIs
988        let mut found_uris: Vec<String> = Vec::new();
989        for group_lock in &loaded_groups {
990            let group = group_lock.read().await;
991            for uri in group.uris() {
992                found_uris.push(uri.clone());
993            }
994        }
995
996        assert!(
997            found_uris.iter().any(|u| u.contains("file0.bin")),
998            "Should restore first file URI"
999        );
1000        assert!(
1001            found_uris.iter().any(|u| u.contains("file1.bin")),
1002            "Should restore second file URI"
1003        );
1004
1005        // Clean up
1006        let _ = fs::remove_dir_all(&session_dir);
1007    }
1008
1009    #[tokio::test]
1010    async fn test_session_save_empty_no_error() {
1011        let session_dir = create_test_session_dir();
1012        let persistence = SessionPersistence::new(&session_dir);
1013
1014        // Save empty groups list - should succeed without error
1015        let empty_groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1016        let result = persistence.save_state(&empty_groups).await;
1017
1018        assert!(result.is_ok(), "Saving empty session should not error");
1019        let saved_count = result.unwrap();
1020        assert_eq!(saved_count, 0, "Empty session should report 0 saved");
1021
1022        // Session directory should still exist (with options file at least)
1023        assert!(
1024            session_dir.exists(),
1025            "Session dir should be created even for empty save"
1026        );
1027
1028        // Clean up
1029        let _ = fs::remove_dir_all(&session_dir);
1030    }
1031
1032    #[tokio::test]
1033    async fn test_session_corrupted_file_skipped_gracefully() {
1034        let session_dir = create_test_session_dir();
1035
1036        // Create a corrupted .aria2 file
1037        let corrupt_file = session_dir.join("corrupt-gid.aria2");
1038        fs::write(&corrupt_file, "THIS IS NOT VALID JSON {{{{").expect("Should write corrupt file");
1039
1040        // Also create a valid .aria2 file
1041        let valid_file = session_dir.join("valid-gid.aria2");
1042        let valid_resume_data = ResumeData {
1043            gid: "valid-gid-12345".to_string(),
1044            uris: vec![crate::engine::resume_data::UriState {
1045                uri: "http://example.com/valid-file.bin".to_string(),
1046                tried: true,
1047                used: false,
1048                last_result: None,
1049                speed_bytes_per_sec: None,
1050            }],
1051            total_length: 1024,
1052            completed_length: 512,
1053            uploaded_length: 0,
1054            bitfield: vec![],
1055            num_pieces: None,
1056            piece_length: None,
1057            status: "paused".to_string(),
1058            error_message: None,
1059            last_download_time: 0,
1060            created_at: 0,
1061            output_path: Some("/downloads/valid-file.bin".to_string()),
1062            checksum: None,
1063            options: std::collections::HashMap::new(),
1064            resume_offset: Some(512),
1065            bt_info_hash: None,
1066            bt_saved_metadata_path: None,
1067        };
1068        valid_resume_data
1069            .save_to_file(&valid_file)
1070            .expect("Should write valid file");
1071
1072        let mut persistence = SessionPersistence::new(&session_dir);
1073        let mut loaded_groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1074
1075        // Load should succeed despite corrupt file
1076        let result = persistence.load_state(&mut loaded_groups).await;
1077
1078        assert!(result.is_ok(), "Load should succeed despite corrupt file");
1079        let loaded_count = result.unwrap();
1080        assert_eq!(
1081            loaded_count, 1,
1082            "Should load 1 valid file (corrupt one skipped)"
1083        );
1084
1085        // Verify the valid one was loaded correctly
1086        assert_eq!(loaded_groups.len(), 1, "Should have 1 restored group");
1087
1088        // Clean up
1089        let _ = fs::remove_dir_all(&session_dir);
1090    }
1091
1092    #[tokio::test]
1093    async fn test_session_load_nonexistent_dir_returns_zero() {
1094        let nonexistent_dir =
1095            PathBuf::from("/tmp/aria2_nonexistent_test_dir_that_should_not_exist_12345");
1096        let mut persistence = SessionPersistence::new(&nonexistent_dir);
1097
1098        let mut groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1099        let result = persistence.load_state(&mut groups).await;
1100
1101        assert!(result.is_ok(), "Nonexistent dir should return Ok");
1102        assert_eq!(result.unwrap(), 0, "Nonexistent dir should return 0 loaded");
1103        assert!(groups.is_empty(), "No groups should be added");
1104    }
1105
1106    #[tokio::test]
1107    async fn test_session_cleanup_removes_all_files() {
1108        let session_dir = create_test_session_dir();
1109        let persistence = SessionPersistence::new(&session_dir);
1110
1111        // Create some files
1112        let groups = create_test_groups(2);
1113        let _ = persistence.save_state(&groups).await.unwrap();
1114
1115        // Verify files exist
1116        assert!(
1117            session_dir.exists(),
1118            "Session dir should exist before cleanup"
1119        );
1120
1121        // Cleanup
1122        persistence.cleanup().await.expect("Cleanup should succeed");
1123
1124        // Verify directory is empty or removed
1125        if session_dir.exists() {
1126            let remaining: Vec<_> = fs::read_dir(&session_dir)
1127                .expect("Should read dir")
1128                .filter_map(|e| e.ok())
1129                .collect();
1130            assert!(
1131                remaining.is_empty(),
1132                "All files should be removed after cleanup"
1133            );
1134        }
1135
1136        // Clean up
1137        let _ = fs::remove_dir_all(&session_dir);
1138    }
1139
1140    #[tokio::test]
1141    async fn test_session_custom_interval() {
1142        let session_dir = create_test_session_dir();
1143
1144        let persistence = SessionPersistence::new(&session_dir).with_interval(30);
1145
1146        assert_eq!(
1147            persistence.auto_save_interval,
1148            Duration::from_secs(30),
1149            "Custom interval should be set"
1150        );
1151
1152        // Test minimum interval enforcement
1153        let short_interval = SessionPersistence::new(&session_dir).with_interval(1);
1154        assert!(
1155            short_interval.auto_save_interval >= Duration::from_secs(10),
1156            "Interval should be at least 10 seconds"
1157        );
1158
1159        let _ = fs::remove_dir_all(&session_dir);
1160    }
1161
1162    #[tokio::test]
1163    async fn test_resume_data_roundtrip_via_persistence() {
1164        let session_dir = create_test_session_dir();
1165        let mut persistence = SessionPersistence::new(&session_dir);
1166
1167        // Create a group with specific properties
1168        let gid = GroupId::new(0xDEADBEEF);
1169        let options = DownloadOptions {
1170            dir: Some("/test/downloads".to_string()),
1171            out: Some("special_file.iso".to_string()),
1172            split: Some(16),
1173            ..Default::default()
1174        };
1175        let group = Arc::new(RwLock::new(RequestGroup::new(
1176            gid,
1177            vec!["http://example.com/special_file.iso".to_string()],
1178            options,
1179        )));
1180
1181        // Set some progress
1182        {
1183            let g = group.write().await;
1184            g.set_total_length_atomic(10485760); // 10MB
1185            g.set_completed_length(5242880); // 5MB
1186        }
1187
1188        // Save
1189        let saved = persistence.save_state(&[group]).await.unwrap();
1190        assert_eq!(saved, 1);
1191
1192        // Load back
1193        let mut loaded: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1194        let loaded_count = persistence.load_state(&mut loaded).await.unwrap();
1195        assert_eq!(loaded_count, 1);
1196
1197        // Verify the loaded group has correct URIs
1198        let restored = loaded[0].read().await;
1199        let uris = restored.uris();
1200        assert_eq!(uris.len(), 1);
1201        assert!(uris[0].contains("special_file.iso"));
1202
1203        // Clean up
1204        let _ = fs::remove_dir_all(&session_dir);
1205    }
1206
1207    // =====================================================================
1208    // K2.4 — New Tests for Session Enhancements
1209    // =====================================================================
1210
1211    /// Test K2.4 #1: Selective save of active downloads only.
1212    ///
1213    /// Creates a mix of active and completed downloads, then verifies that
1214    /// save_active_only() only persists the active/waiting ones.
1215    #[tokio::test]
1216    async fn test_selective_save_active_only() {
1217        let session_dir = create_test_session_dir();
1218        let persistence = SessionPersistence::new(&session_dir);
1219
1220        // Create groups with different statuses
1221        let mut groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1222
1223        // Active download (should be saved)
1224        let active_gid = GroupId::new(1001);
1225        let active_group = Arc::new(RwLock::new(RequestGroup::new(
1226            active_gid,
1227            vec!["http://example.com/active.bin".to_string()],
1228            DownloadOptions::default(),
1229        )));
1230        {
1231            let mut g = active_group.write().await;
1232            g.start().await.unwrap(); // Set to Active status
1233        }
1234        groups.push(active_group);
1235
1236        // Waiting download (should be saved)
1237        let waiting_gid = GroupId::new(1002);
1238        let waiting_group = Arc::new(RwLock::new(RequestGroup::new(
1239            waiting_gid,
1240            vec!["http://example.com/waiting.bin".to_string()],
1241            DownloadOptions::default(),
1242        )));
1243        // Waiting is default status, no need to change
1244        groups.push(waiting_group);
1245
1246        // Completed download (should NOT be saved)
1247        let complete_gid = GroupId::new(1003);
1248        let complete_group = Arc::new(RwLock::new(RequestGroup::new(
1249            complete_gid,
1250            vec!["http://example.com/complete.bin".to_string()],
1251            DownloadOptions::default(),
1252        )));
1253        {
1254            let mut g = complete_group.write().await;
1255            g.complete().await.unwrap(); // Set to Complete status
1256        }
1257        groups.push(complete_group);
1258
1259        // Save only active/waiting
1260        let saved_count = persistence.save_active_only(&groups).await.unwrap();
1261
1262        // Should save exactly 2 (active + waiting)
1263        assert_eq!(
1264            saved_count, 2,
1265            "save_active_only should save only active and waiting downloads"
1266        );
1267
1268        // Verify files on disk - should have 2 .aria2 files
1269        let entries: Vec<_> = fs::read_dir(&session_dir)
1270            .expect("Should read session dir")
1271            .filter_map(|e| e.ok())
1272            .filter(|e| {
1273                e.path()
1274                    .extension()
1275                    .map(|ext| ext == "aria2")
1276                    .unwrap_or(false)
1277            })
1278            .collect();
1279
1280        assert_eq!(
1281            entries.len(),
1282            2,
1283            "Should have exactly 2 .aria2 files for active+waiting"
1284        );
1285
1286        // Clean up
1287        let _ = fs::remove_dir_all(&session_dir);
1288    }
1289
1290    /// Test K2.4 #2: DHT snapshot roundtrip preserves data.
1291    ///
1292    /// Creates a DhtStateSnapshot with sample data, serializes it to JSON,
1293    /// then deserializes and verifies all fields are preserved correctly.
1294    #[test]
1295    fn test_dht_snapshot_roundtrip() {
1296        use crate::session::session_persistence::{DhtNodeInfo, DhtStateSnapshot};
1297
1298        // Create original snapshot with data
1299        let node1 = DhtNodeInfo {
1300            id: [1u8; 20],
1301            addr: "192.168.1.100:6881".to_string(),
1302            last_seen_epoch_secs: 1700000000,
1303        };
1304
1305        let node2 = DhtNodeInfo {
1306            id: [2u8; 20],
1307            addr: "10.0.0.5:6881".to_string(),
1308            last_seen_epoch_secs: 1700000100,
1309        };
1310
1311        let token_secret: [u8; 20] = [0xAB; 20];
1312
1313        let original = DhtStateSnapshot::new(vec![node1, node2], token_secret, Some(1699999000));
1314
1315        // Verify initial state
1316        assert_eq!(original.total_nodes, 2);
1317        assert_eq!(original.nodes.len(), 2);
1318        assert!(original.last_bootstrap_epoch_secs.is_some());
1319
1320        // Serialize to JSON
1321        let json = original
1322            .to_json_string()
1323            .expect("Serialization should succeed");
1324        assert!(!json.is_empty(), "JSON output should not be empty");
1325        assert!(
1326            json.contains("192.168.1.100"),
1327            "JSON should contain first node address"
1328        );
1329        assert!(
1330            json.contains("10.0.0.5"),
1331            "JSON should contain second node address"
1332        );
1333
1334        // Deserialize from JSON
1335        let restored =
1336            DhtStateSnapshot::from_json_string(&json).expect("Deserialization should succeed");
1337
1338        // Verify all fields match
1339        assert_eq!(restored.total_nodes, 2, "total_nodes should be preserved");
1340        assert_eq!(restored.nodes.len(), 2, "nodes count should be preserved");
1341        assert_eq!(
1342            restored.token_secret, token_secret,
1343            "token_secret should be preserved"
1344        );
1345        assert_eq!(
1346            restored.last_bootstrap_epoch_secs,
1347            Some(1699999000),
1348            "last_bootstrap_epoch_secs should be preserved"
1349        );
1350
1351        // Verify individual node data
1352        assert_eq!(
1353            restored.nodes[0].id, [1u8; 20],
1354            "First node ID should match"
1355        );
1356        assert_eq!(
1357            restored.nodes[0].addr, "192.168.1.100:6881",
1358            "First node address should match"
1359        );
1360        assert_eq!(
1361            restored.nodes[0].last_seen_epoch_secs, 1700000000,
1362            "First node timestamp should match"
1363        );
1364
1365        assert_eq!(
1366            restored.nodes[1].id, [2u8; 20],
1367            "Second node ID should match"
1368        );
1369        assert_eq!(
1370            restored.nodes[1].addr, "10.0.0.5:6881",
1371            "Second node address should match"
1372        );
1373
1374        // Test empty snapshot
1375        let empty = DhtStateSnapshot::empty();
1376        assert_eq!(empty.total_nodes, 0, "Empty snapshot should have 0 nodes");
1377        assert!(
1378            empty.nodes.is_empty(),
1379            "Empty snapshot should have no nodes"
1380        );
1381
1382        let empty_json = empty
1383            .to_json_string()
1384            .expect("Empty serialization should succeed");
1385        let empty_restored = DhtStateSnapshot::from_json_string(&empty_json)
1386            .expect("Empty deserialization should work");
1387        assert_eq!(
1388            empty_restored.total_nodes, 0,
1389            "Restored empty should still be empty"
1390        );
1391    }
1392
1393    /// Test K2.4 #3: Cookie persistence integration - cookies survive save/load cycle.
1394    ///
1395    /// Creates a SessionPersistence with cookies, saves state, loads it into
1396    /// a new instance, and verifies cookies are preserved.
1397    #[tokio::test]
1398    async fn test_cookie_persist_integration() {
1399        use crate::http::cookie_storage::{CookieJar, JarCookie};
1400
1401        let session_dir = create_test_session_dir();
1402
1403        // Create original session with cookie jar
1404        let mut jar = CookieJar::new();
1405        jar.store(JarCookie::new("session_id", "abc123", "example.com"));
1406        jar.store(JarCookie::new("auth_token", "xyz789", "api.example.com"));
1407
1408        let persistence_with_cookies = SessionPersistence::new(&session_dir).with_cookie_jar(jar);
1409
1410        // Verify cookies are set
1411        assert!(
1412            persistence_with_cookies.cookie_jar().is_some(),
1413            "Cookie jar should be set"
1414        );
1415        assert_eq!(
1416            persistence_with_cookies.cookie_jar().unwrap().len(),
1417            2,
1418            "Should have 2 cookies before save"
1419        );
1420
1421        // Save session (includes cookies)
1422        let groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1423        let _saved = persistence_with_cookies.save_state(&groups).await.unwrap();
1424
1425        // Verify cookies.json file was created
1426        let cookie_path = session_dir.join("cookies.json");
1427        assert!(
1428            cookie_path.exists(),
1429            "cookies.json file should exist after save"
1430        );
1431
1432        // Load into new instance (without pre-set cookies)
1433        let mut persistence_new = SessionPersistence::new(&session_dir);
1434        let mut loaded_groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1435        let _loaded = persistence_new
1436            .load_state(&mut loaded_groups)
1437            .await
1438            .unwrap();
1439
1440        // Verify cookies were loaded
1441        assert!(
1442            persistence_new.cookie_jar().is_some(),
1443            "Cookie jar should exist after load"
1444        );
1445        let loaded_jar = persistence_new.cookie_jar().unwrap();
1446        assert_eq!(
1447            loaded_jar.len(),
1448            2,
1449            "Should have loaded 2 cookies from file"
1450        );
1451
1452        // Verify specific cookies were preserved
1453        let example_cookies = loaded_jar.get_cookies_for_url("http://example.com/", false);
1454        assert_eq!(
1455            example_cookies.len(),
1456            1,
1457            "Should find 1 cookie for example.com"
1458        );
1459        assert_eq!(example_cookies[0].name, "session_id");
1460        assert_eq!(example_cookies[0].value, "abc123");
1461
1462        let api_cookies = loaded_jar.get_cookies_for_url("http://api.example.com/api", false);
1463        assert_eq!(
1464            api_cookies.len(),
1465            2,
1466            "Should find 2 cookies for api.example.com (parent domain + exact)"
1467        );
1468        let auth_cookie = api_cookies
1469            .iter()
1470            .find(|c| c.name == "auth_token")
1471            .expect("Should find auth_token cookie");
1472        assert_eq!(auth_cookie.value, "xyz789");
1473        let session_cookie = api_cookies
1474            .iter()
1475            .find(|c| c.name == "session_id")
1476            .expect("Should find session_id cookie from parent domain");
1477        assert_eq!(session_cookie.value, "abc123");
1478
1479        // Clean up
1480        let _ = fs::remove_dir_all(&session_dir);
1481    }
1482
1483    /// Test K2.4 #4: Auto-save with custom interval works correctly.
1484    ///
1485    /// Verifies that non-default intervals are accepted and stored properly,
1486    /// including enforcement of minimum interval requirement.
1487    #[tokio::test]
1488    async fn test_auto_save_with_custom_interval() {
1489        let session_dir = create_test_session_dir();
1490
1491        // Test custom interval of 30 seconds
1492        let persistence_30s = SessionPersistence::new(&session_dir).with_interval(30);
1493        assert_eq!(
1494            persistence_30s.auto_save_interval,
1495            Duration::from_secs(30),
1496            "Custom 30s interval should be set"
1497        );
1498
1499        // Test very short interval gets clamped to minimum (10 seconds)
1500        let persistence_too_short = SessionPersistence::new(&session_dir).with_interval(1);
1501        assert!(
1502            persistence_too_short.auto_save_interval >= Duration::from_secs(10),
1503            "Interval below minimum should be clamped to 10s"
1504        );
1505
1506        // Test exact minimum interval
1507        let persistence_exact_min = SessionPersistence::new(&session_dir).with_interval(10);
1508        assert_eq!(
1509            persistence_exact_min.auto_save_interval,
1510            Duration::from_secs(10),
1511            "Exact minimum interval (10s) should be accepted"
1512        );
1513
1514        // Test large interval
1515        let persistence_large = SessionPersistence::new(&session_dir).with_interval(300); // 5 minutes
1516        assert_eq!(
1517            persistence_large.auto_save_interval,
1518            Duration::from_secs(300),
1519            "Large interval (300s) should be accepted"
1520        );
1521
1522        // Verify auto-save is enabled by default
1523        let persistence_default = SessionPersistence::new(&session_dir);
1524        assert!(
1525            persistence_default.auto_save_enabled,
1526            "Auto-save should be enabled by default"
1527        );
1528        assert_eq!(
1529            persistence_default.auto_save_interval,
1530            Duration::from_secs(DEFAULT_AUTO_SAVE_INTERVAL_SECS),
1531            "Default interval should be 60 seconds"
1532        );
1533
1534        // Verify without_auto_save disables it
1535        let persistence_disabled = SessionPersistence::new(&session_dir).without_auto_save();
1536        assert!(
1537            !persistence_disabled.auto_save_enabled,
1538            "Auto-save should be disabled after without_auto_save()"
1539        );
1540
1541        // Clean up
1542        let _ = fs::remove_dir_all(&session_dir);
1543    }
1544}