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