Skip to main content

chrome_cli/
session.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6/// Session file content persisted between CLI invocations.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SessionData {
9    pub ws_url: String,
10    pub port: u16,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub pid: Option<u32>,
13    #[serde(skip_serializing_if = "Option::is_none", default)]
14    pub active_tab_id: Option<String>,
15    pub timestamp: String,
16}
17
18/// Errors that can occur during session file operations.
19#[derive(Debug)]
20pub enum SessionError {
21    /// Could not determine home directory.
22    NoHomeDir,
23    /// I/O error reading/writing session file.
24    Io(std::io::Error),
25    /// Session file contains invalid JSON.
26    InvalidFormat(String),
27}
28
29impl fmt::Display for SessionError {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::NoHomeDir => write!(f, "could not determine home directory"),
33            Self::Io(e) => write!(f, "session file error: {e}"),
34            Self::InvalidFormat(e) => write!(f, "invalid session file: {e}"),
35        }
36    }
37}
38
39impl std::error::Error for SessionError {
40    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
41        match self {
42            Self::Io(e) => Some(e),
43            _ => None,
44        }
45    }
46}
47
48impl From<std::io::Error> for SessionError {
49    fn from(e: std::io::Error) -> Self {
50        Self::Io(e)
51    }
52}
53
54impl From<SessionError> for crate::error::AppError {
55    fn from(e: SessionError) -> Self {
56        use crate::error::ExitCode;
57        Self {
58            message: e.to_string(),
59            code: ExitCode::GeneralError,
60            custom_json: None,
61        }
62    }
63}
64
65/// Returns the path to the session file: `~/.chrome-cli/session.json`.
66///
67/// Uses `$HOME` on Unix and `%USERPROFILE%` on Windows.
68///
69/// # Errors
70///
71/// Returns `SessionError::NoHomeDir` if the home directory cannot be determined.
72pub fn session_file_path() -> Result<PathBuf, SessionError> {
73    let home = home_dir()?;
74    Ok(home.join(".chrome-cli").join("session.json"))
75}
76
77fn home_dir() -> Result<PathBuf, SessionError> {
78    #[cfg(unix)]
79    let key = "HOME";
80    #[cfg(windows)]
81    let key = "USERPROFILE";
82
83    std::env::var(key)
84        .map(PathBuf::from)
85        .map_err(|_| SessionError::NoHomeDir)
86}
87
88/// Write session data to the session file. Creates `~/.chrome-cli/` if needed.
89///
90/// Uses atomic write (write to temp file then rename) and sets file permissions
91/// to `0o600` on Unix.
92///
93/// # Errors
94///
95/// Returns `SessionError::Io` on I/O failure or `SessionError::NoHomeDir` if the
96/// home directory cannot be determined.
97pub fn write_session(data: &SessionData) -> Result<(), SessionError> {
98    let path = session_file_path()?;
99    write_session_to(&path, data)
100}
101
102/// Write session data to a specific path. Testable variant of [`write_session`].
103///
104/// # Errors
105///
106/// Returns `SessionError::Io` on I/O failure.
107pub fn write_session_to(path: &std::path::Path, data: &SessionData) -> Result<(), SessionError> {
108    if let Some(parent) = path.parent() {
109        std::fs::create_dir_all(parent)?;
110        #[cfg(unix)]
111        {
112            use std::os::unix::fs::PermissionsExt;
113            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
114        }
115    }
116
117    let json = serde_json::to_string_pretty(data)
118        .map_err(|e| SessionError::InvalidFormat(e.to_string()))?;
119
120    // Atomic write: write to temp file, then rename
121    let tmp_path = path.with_extension("json.tmp");
122    std::fs::write(&tmp_path, &json)?;
123
124    #[cfg(unix)]
125    {
126        use std::os::unix::fs::PermissionsExt;
127        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
128    }
129
130    std::fs::rename(&tmp_path, path)?;
131    Ok(())
132}
133
134/// Read session data from the session file.
135///
136/// Returns `Ok(None)` if the file does not exist.
137///
138/// # Errors
139///
140/// Returns `SessionError::InvalidFormat` if the file contains invalid JSON,
141/// or `SessionError::Io` on other I/O errors.
142pub fn read_session() -> Result<Option<SessionData>, SessionError> {
143    let path = session_file_path()?;
144    read_session_from(&path)
145}
146
147/// Read session data from a specific path. Testable variant of [`read_session`].
148///
149/// # Errors
150///
151/// Returns `SessionError::InvalidFormat` if the file contains invalid JSON,
152/// or `SessionError::Io` on other I/O errors.
153pub fn read_session_from(path: &std::path::Path) -> Result<Option<SessionData>, SessionError> {
154    match std::fs::read_to_string(path) {
155        Ok(contents) => {
156            let data: SessionData = serde_json::from_str(&contents)
157                .map_err(|e| SessionError::InvalidFormat(e.to_string()))?;
158            Ok(Some(data))
159        }
160        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
161        Err(e) => Err(SessionError::Io(e)),
162    }
163}
164
165/// Delete the session file. Returns `Ok(())` even if the file doesn't exist.
166///
167/// # Errors
168///
169/// Returns `SessionError::Io` on I/O errors other than "not found".
170pub fn delete_session() -> Result<(), SessionError> {
171    let path = session_file_path()?;
172    delete_session_from(&path)
173}
174
175/// Delete a session file at a specific path. Testable variant of [`delete_session`].
176///
177/// # Errors
178///
179/// Returns `SessionError::Io` on I/O errors other than "not found".
180pub fn delete_session_from(path: &std::path::Path) -> Result<(), SessionError> {
181    match std::fs::remove_file(path) {
182        Ok(()) => Ok(()),
183        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
184        Err(e) => Err(SessionError::Io(e)),
185    }
186}
187
188/// Format the current time as a simplified ISO 8601 string (e.g., `"2026-02-11T12:00:00Z"`).
189///
190/// Uses the Howard Hinnant algorithm for civil date computation from Unix timestamp.
191#[must_use]
192pub fn now_iso8601() -> String {
193    use std::time::{SystemTime, UNIX_EPOCH};
194
195    let secs = SystemTime::now()
196        .duration_since(UNIX_EPOCH)
197        .unwrap_or_default()
198        .as_secs();
199
200    format_unix_secs(secs)
201}
202
203#[allow(
204    clippy::similar_names,
205    clippy::cast_possible_wrap,
206    clippy::cast_possible_truncation,
207    clippy::cast_sign_loss
208)]
209fn format_unix_secs(secs: u64) -> String {
210    let day_secs = secs % 86_400;
211    let hours = day_secs / 3_600;
212    let minutes = (day_secs % 3_600) / 60;
213    let seconds = day_secs % 60;
214
215    // Howard Hinnant's algorithm for civil date from days since epoch
216    let mut days = (secs / 86_400) as i64;
217    days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
218    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
219    let day_of_era = (days - era * 146_097) as u32; // [0, 146096]
220    let year_of_era =
221        (day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146_096) / 365;
222    let y = i64::from(year_of_era) + era * 400;
223    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); // [0, 365]
224    let mp = (5 * day_of_year + 2) / 153; // month index [0, 11]
225    let d = day_of_year - (153 * mp + 2) / 5 + 1; // day [1, 31]
226    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
227    let y = if m <= 2 { y + 1 } else { y };
228
229    format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn session_file_path_ends_with_expected_suffix() {
238        let path = session_file_path().unwrap();
239        assert!(path.ends_with(".chrome-cli/session.json"));
240    }
241
242    #[test]
243    fn format_unix_epoch() {
244        assert_eq!(format_unix_secs(0), "1970-01-01T00:00:00Z");
245    }
246
247    #[test]
248    fn format_known_timestamp() {
249        // 2001-09-09T01:46:40Z = 1_000_000_000 seconds since epoch (well-known)
250        assert_eq!(format_unix_secs(1_000_000_000), "2001-09-09T01:46:40Z");
251    }
252
253    #[test]
254    fn now_iso8601_produces_valid_format() {
255        let ts = now_iso8601();
256        // Basic format validation: YYYY-MM-DDTHH:MM:SSZ
257        assert_eq!(ts.len(), 20);
258        assert_eq!(&ts[4..5], "-");
259        assert_eq!(&ts[7..8], "-");
260        assert_eq!(&ts[10..11], "T");
261        assert_eq!(&ts[13..14], ":");
262        assert_eq!(&ts[16..17], ":");
263        assert_eq!(&ts[19..20], "Z");
264    }
265
266    #[test]
267    fn write_read_round_trip() {
268        let dir = std::env::temp_dir().join("chrome-cli-test-session-rt");
269        let _ = std::fs::remove_dir_all(&dir);
270        let path = dir.join("session.json");
271
272        let data = SessionData {
273            ws_url: "ws://127.0.0.1:9222/devtools/browser/abc".into(),
274            port: 9222,
275            pid: Some(1234),
276            active_tab_id: None,
277            timestamp: "2026-02-11T12:00:00Z".into(),
278        };
279
280        write_session_to(&path, &data).unwrap();
281        let read = read_session_from(&path).unwrap().unwrap();
282
283        assert_eq!(read.ws_url, data.ws_url);
284        assert_eq!(read.port, data.port);
285        assert_eq!(read.pid, data.pid);
286        assert_eq!(read.active_tab_id, data.active_tab_id);
287        assert_eq!(read.timestamp, data.timestamp);
288
289        let _ = std::fs::remove_dir_all(&dir);
290    }
291
292    #[test]
293    fn write_read_round_trip_no_pid() {
294        let dir = std::env::temp_dir().join("chrome-cli-test-session-nopid");
295        let _ = std::fs::remove_dir_all(&dir);
296        let path = dir.join("session.json");
297
298        let data = SessionData {
299            ws_url: "ws://127.0.0.1:9222/devtools/browser/xyz".into(),
300            port: 9222,
301            pid: None,
302            active_tab_id: None,
303            timestamp: "2026-02-11T12:00:00Z".into(),
304        };
305
306        write_session_to(&path, &data).unwrap();
307        let contents = std::fs::read_to_string(&path).unwrap();
308        assert!(!contents.contains("pid"), "pid should be skipped when None");
309
310        let read = read_session_from(&path).unwrap().unwrap();
311        assert_eq!(read.pid, None);
312
313        let _ = std::fs::remove_dir_all(&dir);
314    }
315
316    #[test]
317    fn read_nonexistent_returns_none() {
318        let path = std::path::Path::new("/tmp/chrome-cli-test-nonexistent/session.json");
319        let result = read_session_from(path).unwrap();
320        assert!(result.is_none());
321    }
322
323    #[test]
324    fn read_invalid_json_returns_error() {
325        let dir = std::env::temp_dir().join("chrome-cli-test-session-invalid");
326        let _ = std::fs::remove_dir_all(&dir);
327        std::fs::create_dir_all(&dir).unwrap();
328        let path = dir.join("session.json");
329        std::fs::write(&path, "not valid json").unwrap();
330
331        let result = read_session_from(&path);
332        assert!(matches!(result, Err(SessionError::InvalidFormat(_))));
333
334        let _ = std::fs::remove_dir_all(&dir);
335    }
336
337    #[test]
338    fn delete_nonexistent_returns_ok() {
339        let path = std::path::Path::new("/tmp/chrome-cli-test-del-nonexist/session.json");
340        assert!(delete_session_from(path).is_ok());
341    }
342
343    #[test]
344    fn delete_existing_removes_file() {
345        let dir = std::env::temp_dir().join("chrome-cli-test-session-del");
346        let _ = std::fs::remove_dir_all(&dir);
347        std::fs::create_dir_all(&dir).unwrap();
348        let path = dir.join("session.json");
349        std::fs::write(&path, "{}").unwrap();
350        assert!(path.exists());
351
352        delete_session_from(&path).unwrap();
353        assert!(!path.exists());
354
355        let _ = std::fs::remove_dir_all(&dir);
356    }
357
358    /// Simulate the PID-preservation logic from `save_session()`: read existing
359    /// session, carry PID forward if ports match and incoming PID is None.
360    fn resolve_pid(
361        path: &std::path::Path,
362        incoming_pid: Option<u32>,
363        incoming_port: u16,
364    ) -> Option<u32> {
365        incoming_pid.or_else(|| {
366            read_session_from(path)
367                .ok()
368                .flatten()
369                .filter(|existing| existing.port == incoming_port)
370                .and_then(|existing| existing.pid)
371        })
372    }
373
374    #[test]
375    fn pid_preserved_when_ports_match() {
376        let dir = std::env::temp_dir().join("chrome-cli-test-pid-preserve");
377        let _ = std::fs::remove_dir_all(&dir);
378        let path = dir.join("session.json");
379
380        // Write initial session with PID (simulates --launch)
381        let launch = SessionData {
382            ws_url: "ws://127.0.0.1:9222/devtools/browser/aaa".into(),
383            port: 9222,
384            pid: Some(54321),
385            active_tab_id: None,
386            timestamp: "2026-02-15T00:00:00Z".into(),
387        };
388        write_session_to(&path, &launch).unwrap();
389
390        // Simulate auto-discover on same port (pid: None)
391        let pid = resolve_pid(&path, None, 9222);
392        assert_eq!(
393            pid,
394            Some(54321),
395            "PID should be preserved from existing session"
396        );
397
398        let _ = std::fs::remove_dir_all(&dir);
399    }
400
401    #[test]
402    fn pid_not_preserved_when_ports_differ() {
403        let dir = std::env::temp_dir().join("chrome-cli-test-pid-nopreserve");
404        let _ = std::fs::remove_dir_all(&dir);
405        let path = dir.join("session.json");
406
407        // Write initial session with PID on port 9222
408        let launch = SessionData {
409            ws_url: "ws://127.0.0.1:9222/devtools/browser/bbb".into(),
410            port: 9222,
411            pid: Some(99999),
412            active_tab_id: None,
413            timestamp: "2026-02-15T00:00:00Z".into(),
414        };
415        write_session_to(&path, &launch).unwrap();
416
417        // Simulate auto-discover on DIFFERENT port (pid: None)
418        let pid = resolve_pid(&path, None, 9333);
419        assert_eq!(pid, None, "PID should NOT be carried from a different port");
420
421        let _ = std::fs::remove_dir_all(&dir);
422    }
423
424    #[test]
425    fn pid_not_injected_when_no_prior_session() {
426        let dir = std::env::temp_dir().join("chrome-cli-test-pid-noinject");
427        let _ = std::fs::remove_dir_all(&dir);
428        // Do NOT create the session file
429
430        let path = dir.join("session.json");
431        let pid = resolve_pid(&path, None, 9222);
432        assert_eq!(
433            pid, None,
434            "No PID should be injected when no prior session exists"
435        );
436
437        let _ = std::fs::remove_dir_all(&dir);
438    }
439
440    #[test]
441    fn incoming_pid_takes_priority_over_existing() {
442        let dir = std::env::temp_dir().join("chrome-cli-test-pid-priority");
443        let _ = std::fs::remove_dir_all(&dir);
444        let path = dir.join("session.json");
445
446        // Write existing session with PID
447        let existing = SessionData {
448            ws_url: "ws://127.0.0.1:9222/devtools/browser/ccc".into(),
449            port: 9222,
450            pid: Some(11111),
451            active_tab_id: None,
452            timestamp: "2026-02-15T00:00:00Z".into(),
453        };
454        write_session_to(&path, &existing).unwrap();
455
456        // Incoming ConnectionInfo has its own PID (e.g. new --launch)
457        let pid = resolve_pid(&path, Some(22222), 9222);
458        assert_eq!(pid, Some(22222), "Incoming PID should take priority");
459
460        let _ = std::fs::remove_dir_all(&dir);
461    }
462
463    #[test]
464    fn write_read_round_trip_with_active_tab_id() {
465        let dir = std::env::temp_dir().join("chrome-cli-test-session-active-tab");
466        let _ = std::fs::remove_dir_all(&dir);
467        let path = dir.join("session.json");
468
469        let data = SessionData {
470            ws_url: "ws://127.0.0.1:9222/devtools/browser/tab".into(),
471            port: 9222,
472            pid: Some(1234),
473            active_tab_id: Some("ABCDEF123456".into()),
474            timestamp: "2026-02-17T12:00:00Z".into(),
475        };
476
477        write_session_to(&path, &data).unwrap();
478        let read = read_session_from(&path).unwrap().unwrap();
479
480        assert_eq!(read.active_tab_id, Some("ABCDEF123456".into()));
481
482        let _ = std::fs::remove_dir_all(&dir);
483    }
484
485    #[test]
486    fn active_tab_id_skipped_when_none() {
487        let dir = std::env::temp_dir().join("chrome-cli-test-session-no-active-tab");
488        let _ = std::fs::remove_dir_all(&dir);
489        let path = dir.join("session.json");
490
491        let data = SessionData {
492            ws_url: "ws://127.0.0.1:9222/devtools/browser/tab".into(),
493            port: 9222,
494            pid: None,
495            active_tab_id: None,
496            timestamp: "2026-02-17T12:00:00Z".into(),
497        };
498
499        write_session_to(&path, &data).unwrap();
500        let contents = std::fs::read_to_string(&path).unwrap();
501        assert!(
502            !contents.contains("active_tab_id"),
503            "active_tab_id should be skipped when None"
504        );
505
506        let _ = std::fs::remove_dir_all(&dir);
507    }
508
509    #[test]
510    fn old_session_without_active_tab_id_deserializes() {
511        let dir = std::env::temp_dir().join("chrome-cli-test-session-compat");
512        let _ = std::fs::remove_dir_all(&dir);
513        std::fs::create_dir_all(&dir).unwrap();
514        let path = dir.join("session.json");
515
516        // Simulate an old session file that doesn't have active_tab_id
517        let old_json = r#"{
518            "ws_url": "ws://127.0.0.1:9222/devtools/browser/old",
519            "port": 9222,
520            "pid": 5678,
521            "timestamp": "2026-02-17T12:00:00Z"
522        }"#;
523        std::fs::write(&path, old_json).unwrap();
524
525        let read = read_session_from(&path).unwrap().unwrap();
526        assert_eq!(read.active_tab_id, None);
527        assert_eq!(read.pid, Some(5678));
528
529        let _ = std::fs::remove_dir_all(&dir);
530    }
531
532    #[test]
533    fn session_error_display() {
534        assert_eq!(
535            SessionError::NoHomeDir.to_string(),
536            "could not determine home directory"
537        );
538        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
539        assert_eq!(
540            SessionError::Io(io_err).to_string(),
541            "session file error: denied"
542        );
543        assert_eq!(
544            SessionError::InvalidFormat("bad json".into()).to_string(),
545            "invalid session file: bad json"
546        );
547    }
548}