Skip to main content

rustant_core/browser/
persistence.rs

1//! Browser session persistence for reconnecting across Rustant sessions.
2//!
3//! Saves browser connection info (debug port, WebSocket URL, tabs) to
4//! `.rustant/browser-session.json` so subsequent Rustant invocations can
5//! reconnect to the same Chrome instance instead of launching a new one.
6
7use crate::browser::cdp::TabInfo;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::io;
11use std::path::{Path, PathBuf};
12
13/// Persisted browser connection metadata.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct BrowserConnectionInfo {
16    pub debug_port: u16,
17    pub ws_url: Option<String>,
18    pub user_data_dir: Option<PathBuf>,
19    pub tabs: Vec<TabInfo>,
20    pub active_tab_id: Option<String>,
21    pub saved_at: DateTime<Utc>,
22}
23
24/// Handles saving/loading browser session files.
25pub struct BrowserSessionStore;
26
27const SESSION_FILE: &str = ".rustant/browser-session.json";
28
29impl BrowserSessionStore {
30    /// Save browser connection info to the workspace.
31    ///
32    /// Uses atomic write (write to temp file, then rename) to prevent
33    /// corruption if the process crashes mid-write.
34    pub fn save(workspace: &Path, info: &BrowserConnectionInfo) -> io::Result<()> {
35        let path = workspace.join(SESSION_FILE);
36        if let Some(parent) = path.parent() {
37            std::fs::create_dir_all(parent)?;
38        }
39        let json = serde_json::to_string_pretty(info).map_err(io::Error::other)?;
40        let tmp_path = path.with_extension("json.tmp");
41        std::fs::write(&tmp_path, &json)?;
42        std::fs::rename(&tmp_path, &path)
43    }
44
45    /// Load saved browser connection info, if it exists and is still recent.
46    ///
47    /// Returns `None` if the file doesn't exist or is older than `max_age`.
48    pub fn load(workspace: &Path) -> io::Result<Option<BrowserConnectionInfo>> {
49        let path = workspace.join(SESSION_FILE);
50        if !path.exists() {
51            return Ok(None);
52        }
53        let json = std::fs::read_to_string(&path)?;
54        let info: BrowserConnectionInfo = serde_json::from_str(&json)
55            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
56
57        // Discard sessions older than 24 hours — Chrome was probably restarted.
58        let age = Utc::now() - info.saved_at;
59        if age.num_hours() > 24 {
60            // Stale session file — remove it.
61            let _ = std::fs::remove_file(&path);
62            return Ok(None);
63        }
64
65        Ok(Some(info))
66    }
67
68    /// Remove the saved session file.
69    pub fn clear(workspace: &Path) -> io::Result<()> {
70        let path = workspace.join(SESSION_FILE);
71        if path.exists() {
72            std::fs::remove_file(&path)?;
73        }
74        Ok(())
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use tempfile::TempDir;
82
83    fn make_info() -> BrowserConnectionInfo {
84        BrowserConnectionInfo {
85            debug_port: 9222,
86            ws_url: Some("ws://127.0.0.1:9222/devtools/browser/abc".to_string()),
87            user_data_dir: None,
88            tabs: vec![
89                TabInfo {
90                    id: "tab-0".to_string(),
91                    url: "https://example.com".to_string(),
92                    title: "Example".to_string(),
93                    active: true,
94                },
95                TabInfo {
96                    id: "tab-1".to_string(),
97                    url: "https://rust-lang.org".to_string(),
98                    title: "Rust".to_string(),
99                    active: false,
100                },
101            ],
102            active_tab_id: Some("tab-0".to_string()),
103            saved_at: Utc::now(),
104        }
105    }
106
107    #[test]
108    fn test_save_and_load() {
109        let dir = TempDir::new().unwrap();
110        let info = make_info();
111        BrowserSessionStore::save(dir.path(), &info).unwrap();
112
113        let loaded = BrowserSessionStore::load(dir.path()).unwrap().unwrap();
114        assert_eq!(loaded.debug_port, 9222);
115        assert_eq!(loaded.tabs.len(), 2);
116        assert_eq!(loaded.active_tab_id, Some("tab-0".to_string()));
117        assert!(loaded.ws_url.is_some());
118    }
119
120    #[test]
121    fn test_load_missing_returns_none() {
122        let dir = TempDir::new().unwrap();
123        let loaded = BrowserSessionStore::load(dir.path()).unwrap();
124        assert!(loaded.is_none());
125    }
126
127    #[test]
128    fn test_clear_removes_file() {
129        let dir = TempDir::new().unwrap();
130        let info = make_info();
131        BrowserSessionStore::save(dir.path(), &info).unwrap();
132
133        let path = dir.path().join(SESSION_FILE);
134        assert!(path.exists());
135
136        BrowserSessionStore::clear(dir.path()).unwrap();
137        assert!(!path.exists());
138    }
139
140    #[test]
141    fn test_clear_missing_is_ok() {
142        let dir = TempDir::new().unwrap();
143        BrowserSessionStore::clear(dir.path()).unwrap();
144    }
145
146    #[test]
147    fn test_stale_session_discarded() {
148        let dir = TempDir::new().unwrap();
149        let mut info = make_info();
150        // Set saved_at to 25 hours ago.
151        info.saved_at = Utc::now() - chrono::Duration::hours(25);
152        BrowserSessionStore::save(dir.path(), &info).unwrap();
153
154        let loaded = BrowserSessionStore::load(dir.path()).unwrap();
155        assert!(loaded.is_none());
156
157        // File should be cleaned up.
158        let path = dir.path().join(SESSION_FILE);
159        assert!(!path.exists());
160    }
161}