Skip to main content

agentchrome/
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    /// ISO 8601 timestamp of the most recent auto-reconnect, or `None` if never.
17    #[serde(skip_serializing_if = "Option::is_none", default)]
18    pub last_reconnect_at: Option<String>,
19    /// Cumulative successful auto-reconnects for this session file.
20    #[serde(default)]
21    pub reconnect_count: u32,
22}
23
24/// Errors that can occur during session file operations.
25#[derive(Debug)]
26pub enum SessionError {
27    /// Could not determine home directory. Carries a diagnostic listing the
28    /// environment variables consulted so Windows misconfiguration is
29    /// self-evident in the error message.
30    NoHomeDir(String),
31    /// I/O error reading/writing session file.
32    Io(std::io::Error),
33    /// Session file contains invalid JSON. Includes the resolved file path so
34    /// users' first troubleshooting step is obvious.
35    InvalidFormat(String),
36}
37
38impl fmt::Display for SessionError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::NoHomeDir(diag) => {
42                write!(f, "could not determine home directory ({diag})")
43            }
44            Self::Io(e) => write!(f, "session file error: {e}"),
45            Self::InvalidFormat(e) => write!(f, "invalid session file: {e}"),
46        }
47    }
48}
49
50impl std::error::Error for SessionError {
51    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
52        match self {
53            Self::Io(e) => Some(e),
54            _ => None,
55        }
56    }
57}
58
59impl From<std::io::Error> for SessionError {
60    fn from(e: std::io::Error) -> Self {
61        Self::Io(e)
62    }
63}
64
65impl From<SessionError> for crate::error::AppError {
66    fn from(e: SessionError) -> Self {
67        use crate::error::ExitCode;
68        Self {
69            message: e.to_string(),
70            code: ExitCode::GeneralError,
71            custom_json: None,
72        }
73    }
74}
75
76/// Returns the path to the session file: `~/.agentchrome/session.json`.
77///
78/// Uses `$HOME` on Unix and `%USERPROFILE%` on Windows.
79///
80/// # Errors
81///
82/// Returns `SessionError::NoHomeDir` if the home directory cannot be determined.
83pub fn session_file_path() -> Result<PathBuf, SessionError> {
84    let home = home_dir()?;
85    Ok(home.join(".agentchrome").join("session.json"))
86}
87
88fn home_dir() -> Result<PathBuf, SessionError> {
89    #[cfg(unix)]
90    {
91        std::env::var("HOME")
92            .map(PathBuf::from)
93            .map_err(|_| SessionError::NoHomeDir("HOME env var is unset or invalid".to_string()))
94    }
95
96    #[cfg(windows)]
97    {
98        windows_home_chain(&|k| std::env::var(k).ok())
99    }
100}
101
102/// Shared implementation of the Windows `%USERPROFILE%` → `%HOMEDRIVE%%HOMEPATH%`
103/// fallback chain. Exposed for unit tests on all platforms so the resolution
104/// contract is exercised regardless of the host OS.
105#[cfg_attr(not(windows), allow(dead_code))]
106fn windows_home_chain<F>(get: &F) -> Result<PathBuf, SessionError>
107where
108    F: Fn(&str) -> Option<String>,
109{
110    if let Some(v) = get("USERPROFILE").filter(|s| !s.is_empty()) {
111        return Ok(PathBuf::from(v));
112    }
113    match (
114        get("HOMEDRIVE").filter(|s| !s.is_empty()),
115        get("HOMEPATH").filter(|s| !s.is_empty()),
116    ) {
117        (Some(drive), Some(path)) => Ok(PathBuf::from(format!("{drive}{path}"))),
118        _ => Err(SessionError::NoHomeDir(
119            "checked USERPROFILE (unset), HOMEDRIVE+HOMEPATH (unset)".to_string(),
120        )),
121    }
122}
123
124/// Write session data to the session file. Creates `~/.agentchrome/` if needed.
125///
126/// Uses atomic write (write to temp file then rename) and sets file permissions
127/// to `0o600` on Unix.
128///
129/// # Errors
130///
131/// Returns `SessionError::Io` on I/O failure or `SessionError::NoHomeDir` if the
132/// home directory cannot be determined.
133pub fn write_session(data: &SessionData) -> Result<(), SessionError> {
134    let path = session_file_path()?;
135    write_session_to(&path, data)
136}
137
138/// Write session data to a specific path. Testable variant of [`write_session`].
139///
140/// # Errors
141///
142/// Returns `SessionError::Io` on I/O failure.
143pub fn write_session_to(path: &std::path::Path, data: &SessionData) -> Result<(), SessionError> {
144    if let Some(parent) = path.parent() {
145        std::fs::create_dir_all(parent)?;
146        set_owner_only_perms(parent, 0o700)?;
147    }
148
149    let json = serde_json::to_string_pretty(data)
150        .map_err(|e| SessionError::InvalidFormat(e.to_string()))?;
151
152    write_session_atomic(path, json.as_bytes())
153}
154
155#[cfg(unix)]
156fn set_owner_only_perms(path: &std::path::Path, mode: u32) -> Result<(), SessionError> {
157    use std::os::unix::fs::PermissionsExt;
158    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
159    Ok(())
160}
161
162#[cfg(not(unix))]
163fn set_owner_only_perms(_path: &std::path::Path, _mode: u32) -> Result<(), SessionError> {
164    Ok(())
165}
166
167/// Maximum retry attempts for the temp→final rename on Windows/NTFS. Antivirus
168/// scanners occasionally hold the temp file open briefly after write, so a
169/// bounded retry is more reliable than a single attempt.
170const RENAME_RETRIES: u32 = 5;
171/// Delay between rename retries. Short enough not to noticeably stall a
172/// successful write; long enough to let an AV scanner finish a handle release.
173const RENAME_RETRY_DELAY_MS: u64 = 10;
174
175/// Atomic-write primitive: write to `{path}.tmp`, then rename into place.
176///
177/// On Windows, `std::fs::rename` can fail with `AccessDenied` /
178/// `PermissionDenied` when an antivirus scanner is inspecting the temp file.
179/// We retry a bounded number of times and, if the retries exhaust, fall back
180/// to a direct non-atomic write with a `WARN` line on stderr so the user
181/// knows the atomic guarantee was skipped.
182fn write_session_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), SessionError> {
183    let tmp_path = path.with_extension("json.tmp");
184    std::fs::write(&tmp_path, bytes)?;
185    set_owner_only_perms(&tmp_path, 0o600)?;
186
187    let mut last_err: Option<std::io::Error> = None;
188    for attempt in 0..RENAME_RETRIES {
189        match std::fs::rename(&tmp_path, path) {
190            Ok(()) => return Ok(()),
191            Err(e) if is_transient_rename_error(&e) => {
192                last_err = Some(e);
193                if attempt + 1 < RENAME_RETRIES {
194                    std::thread::sleep(std::time::Duration::from_millis(RENAME_RETRY_DELAY_MS));
195                }
196            }
197            Err(e) => {
198                let _ = std::fs::remove_file(&tmp_path);
199                return Err(SessionError::Io(e));
200            }
201        }
202    }
203
204    // Retries exhausted — fall back to a direct (non-atomic) write so the user
205    // does not lose the session. Warn on stderr per the design.
206    let err_msg = last_err
207        .as_ref()
208        .map_or_else(|| "unknown rename error".to_string(), ToString::to_string);
209    eprintln!(
210        "warning: atomic rename of session file failed after {RENAME_RETRIES} retries ({err_msg}); \
211         falling back to direct write"
212    );
213    let _ = std::fs::remove_file(&tmp_path);
214    std::fs::write(path, bytes)?;
215    set_owner_only_perms(path, 0o600)?;
216    Ok(())
217}
218
219/// Whether a rename failure is worth retrying. Windows AV contention surfaces
220/// as `PermissionDenied`; NTFS sharing violations also map here.
221fn is_transient_rename_error(e: &std::io::Error) -> bool {
222    matches!(
223        e.kind(),
224        std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock
225    )
226}
227
228/// Read session data from the session file.
229///
230/// Returns `Ok(None)` if the file does not exist.
231///
232/// # Errors
233///
234/// Returns `SessionError::InvalidFormat` if the file contains invalid JSON,
235/// or `SessionError::Io` on other I/O errors.
236pub fn read_session() -> Result<Option<SessionData>, SessionError> {
237    let path = session_file_path()?;
238    read_session_from(&path)
239}
240
241/// Read session data from a specific path. Testable variant of [`read_session`].
242///
243/// # Errors
244///
245/// Returns `SessionError::InvalidFormat` if the file contains invalid JSON,
246/// or `SessionError::Io` on other I/O errors.
247pub fn read_session_from(path: &std::path::Path) -> Result<Option<SessionData>, SessionError> {
248    match std::fs::read_to_string(path) {
249        Ok(contents) => {
250            let data: SessionData = serde_json::from_str(&contents)
251                .map_err(|e| SessionError::InvalidFormat(format!("{} at {}", e, path.display())))?;
252            Ok(Some(data))
253        }
254        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
255        Err(e) => Err(SessionError::Io(e)),
256    }
257}
258
259/// Delete the session file. Returns `Ok(())` even if the file doesn't exist.
260///
261/// # Errors
262///
263/// Returns `SessionError::Io` on I/O errors other than "not found".
264pub fn delete_session() -> Result<(), SessionError> {
265    let path = session_file_path()?;
266    delete_session_from(&path)
267}
268
269/// Delete a session file at a specific path. Testable variant of [`delete_session`].
270///
271/// # Errors
272///
273/// Returns `SessionError::Io` on I/O errors other than "not found".
274pub fn delete_session_from(path: &std::path::Path) -> Result<(), SessionError> {
275    match std::fs::remove_file(path) {
276        Ok(()) => Ok(()),
277        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
278        Err(e) => Err(SessionError::Io(e)),
279    }
280}
281
282/// Rewrite the session file with a new WebSocket URL, preserving `pid`, `port`,
283/// and `active_tab_id` from `existing`. Bumps `reconnect_count` and refreshes
284/// `timestamp` and `last_reconnect_at`.
285///
286/// Writes atomically and returns the newly persisted record so callers can use
287/// the updated `ws_url` and telemetry fields.
288///
289/// # Errors
290///
291/// Returns `SessionError::Io` on I/O failure, or `SessionError::NoHomeDir` if
292/// the home directory cannot be determined.
293pub fn rewrite_preserving(
294    existing: &SessionData,
295    new_ws_url: String,
296) -> Result<SessionData, SessionError> {
297    let path = session_file_path()?;
298    rewrite_preserving_to(&path, existing, new_ws_url)
299}
300
301/// Testable variant of [`rewrite_preserving`] that writes to a specific path.
302///
303/// When `new_ws_url` matches the existing URL, returns the existing record
304/// unchanged without writing — this avoids inflating `reconnect_count` and
305/// rewriting the file when rediscovery returned the same endpoint.
306///
307/// # Errors
308///
309/// Returns `SessionError::Io` on I/O failure.
310pub fn rewrite_preserving_to(
311    path: &std::path::Path,
312    existing: &SessionData,
313    new_ws_url: String,
314) -> Result<SessionData, SessionError> {
315    if new_ws_url == existing.ws_url {
316        return Ok(existing.clone());
317    }
318    let now = now_iso8601();
319    let updated = SessionData {
320        ws_url: new_ws_url,
321        port: existing.port,
322        pid: existing.pid,
323        active_tab_id: existing.active_tab_id.clone(),
324        timestamp: now.clone(),
325        last_reconnect_at: Some(now),
326        reconnect_count: existing.reconnect_count.saturating_add(1),
327    };
328    write_session_to(path, &updated)?;
329    Ok(updated)
330}
331
332/// Format the current time as a simplified ISO 8601 string (e.g., `"2026-02-11T12:00:00Z"`).
333///
334/// Uses the Howard Hinnant algorithm for civil date computation from Unix timestamp.
335#[must_use]
336pub fn now_iso8601() -> String {
337    use std::time::{SystemTime, UNIX_EPOCH};
338
339    let secs = SystemTime::now()
340        .duration_since(UNIX_EPOCH)
341        .unwrap_or_default()
342        .as_secs();
343
344    format_unix_secs(secs)
345}
346
347#[allow(
348    clippy::similar_names,
349    clippy::cast_possible_wrap,
350    clippy::cast_possible_truncation,
351    clippy::cast_sign_loss
352)]
353fn format_unix_secs(secs: u64) -> String {
354    let day_secs = secs % 86_400;
355    let hours = day_secs / 3_600;
356    let minutes = (day_secs % 3_600) / 60;
357    let seconds = day_secs % 60;
358
359    // Howard Hinnant's algorithm for civil date from days since epoch
360    let mut days = (secs / 86_400) as i64;
361    days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
362    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
363    let day_of_era = (days - era * 146_097) as u32; // [0, 146096]
364    let year_of_era =
365        (day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146_096) / 365;
366    let y = i64::from(year_of_era) + era * 400;
367    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); // [0, 365]
368    let mp = (5 * day_of_year + 2) / 153; // month index [0, 11]
369    let d = day_of_year - (153 * mp + 2) / 5 + 1; // day [1, 31]
370    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
371    let y = if m <= 2 { y + 1 } else { y };
372
373    format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn session_file_path_ends_with_expected_suffix() {
382        let path = session_file_path().unwrap();
383        assert!(path.ends_with(".agentchrome/session.json"));
384    }
385
386    #[test]
387    fn format_unix_epoch() {
388        assert_eq!(format_unix_secs(0), "1970-01-01T00:00:00Z");
389    }
390
391    #[test]
392    fn format_known_timestamp() {
393        // 2001-09-09T01:46:40Z = 1_000_000_000 seconds since epoch (well-known)
394        assert_eq!(format_unix_secs(1_000_000_000), "2001-09-09T01:46:40Z");
395    }
396
397    #[test]
398    fn now_iso8601_produces_valid_format() {
399        let ts = now_iso8601();
400        // Basic format validation: YYYY-MM-DDTHH:MM:SSZ
401        assert_eq!(ts.len(), 20);
402        assert_eq!(&ts[4..5], "-");
403        assert_eq!(&ts[7..8], "-");
404        assert_eq!(&ts[10..11], "T");
405        assert_eq!(&ts[13..14], ":");
406        assert_eq!(&ts[16..17], ":");
407        assert_eq!(&ts[19..20], "Z");
408    }
409
410    #[test]
411    fn write_read_round_trip() {
412        let dir = std::env::temp_dir().join("agentchrome-test-session-rt");
413        let _ = std::fs::remove_dir_all(&dir);
414        let path = dir.join("session.json");
415
416        let data = SessionData {
417            ws_url: "ws://127.0.0.1:9222/devtools/browser/abc".into(),
418            port: 9222,
419            pid: Some(1234),
420            active_tab_id: None,
421            timestamp: "2026-02-11T12:00:00Z".into(),
422            last_reconnect_at: None,
423            reconnect_count: 0,
424        };
425
426        write_session_to(&path, &data).unwrap();
427        let read = read_session_from(&path).unwrap().unwrap();
428
429        assert_eq!(read.ws_url, data.ws_url);
430        assert_eq!(read.port, data.port);
431        assert_eq!(read.pid, data.pid);
432        assert_eq!(read.active_tab_id, data.active_tab_id);
433        assert_eq!(read.timestamp, data.timestamp);
434
435        let _ = std::fs::remove_dir_all(&dir);
436    }
437
438    #[test]
439    fn write_read_round_trip_no_pid() {
440        let dir = std::env::temp_dir().join("agentchrome-test-session-nopid");
441        let _ = std::fs::remove_dir_all(&dir);
442        let path = dir.join("session.json");
443
444        let data = SessionData {
445            ws_url: "ws://127.0.0.1:9222/devtools/browser/xyz".into(),
446            port: 9222,
447            pid: None,
448            active_tab_id: None,
449            timestamp: "2026-02-11T12:00:00Z".into(),
450            last_reconnect_at: None,
451            reconnect_count: 0,
452        };
453
454        write_session_to(&path, &data).unwrap();
455        let contents = std::fs::read_to_string(&path).unwrap();
456        assert!(!contents.contains("pid"), "pid should be skipped when None");
457
458        let read = read_session_from(&path).unwrap().unwrap();
459        assert_eq!(read.pid, None);
460
461        let _ = std::fs::remove_dir_all(&dir);
462    }
463
464    #[test]
465    fn read_nonexistent_returns_none() {
466        let path = std::path::Path::new("/tmp/agentchrome-test-nonexistent/session.json");
467        let result = read_session_from(path).unwrap();
468        assert!(result.is_none());
469    }
470
471    #[test]
472    fn read_invalid_json_returns_error() {
473        let dir = std::env::temp_dir().join("agentchrome-test-session-invalid");
474        let _ = std::fs::remove_dir_all(&dir);
475        std::fs::create_dir_all(&dir).unwrap();
476        let path = dir.join("session.json");
477        std::fs::write(&path, "not valid json").unwrap();
478
479        let result = read_session_from(&path);
480        assert!(matches!(result, Err(SessionError::InvalidFormat(_))));
481
482        let _ = std::fs::remove_dir_all(&dir);
483    }
484
485    #[test]
486    fn delete_nonexistent_returns_ok() {
487        let path = std::path::Path::new("/tmp/agentchrome-test-del-nonexist/session.json");
488        assert!(delete_session_from(path).is_ok());
489    }
490
491    #[test]
492    fn delete_existing_removes_file() {
493        let dir = std::env::temp_dir().join("agentchrome-test-session-del");
494        let _ = std::fs::remove_dir_all(&dir);
495        std::fs::create_dir_all(&dir).unwrap();
496        let path = dir.join("session.json");
497        std::fs::write(&path, "{}").unwrap();
498        assert!(path.exists());
499
500        delete_session_from(&path).unwrap();
501        assert!(!path.exists());
502
503        let _ = std::fs::remove_dir_all(&dir);
504    }
505
506    /// Simulate the PID-preservation logic from `save_session()`: read existing
507    /// session, carry PID forward if ports match and incoming PID is None.
508    fn resolve_pid(
509        path: &std::path::Path,
510        incoming_pid: Option<u32>,
511        incoming_port: u16,
512    ) -> Option<u32> {
513        incoming_pid.or_else(|| {
514            read_session_from(path)
515                .ok()
516                .flatten()
517                .filter(|existing| existing.port == incoming_port)
518                .and_then(|existing| existing.pid)
519        })
520    }
521
522    #[test]
523    fn pid_preserved_when_ports_match() {
524        let dir = std::env::temp_dir().join("agentchrome-test-pid-preserve");
525        let _ = std::fs::remove_dir_all(&dir);
526        let path = dir.join("session.json");
527
528        // Write initial session with PID (simulates --launch)
529        let launch = SessionData {
530            ws_url: "ws://127.0.0.1:9222/devtools/browser/aaa".into(),
531            port: 9222,
532            pid: Some(54321),
533            active_tab_id: None,
534            timestamp: "2026-02-15T00:00:00Z".into(),
535            last_reconnect_at: None,
536            reconnect_count: 0,
537        };
538        write_session_to(&path, &launch).unwrap();
539
540        // Simulate auto-discover on same port (pid: None)
541        let pid = resolve_pid(&path, None, 9222);
542        assert_eq!(
543            pid,
544            Some(54321),
545            "PID should be preserved from existing session"
546        );
547
548        let _ = std::fs::remove_dir_all(&dir);
549    }
550
551    #[test]
552    fn pid_not_preserved_when_ports_differ() {
553        let dir = std::env::temp_dir().join("agentchrome-test-pid-nopreserve");
554        let _ = std::fs::remove_dir_all(&dir);
555        let path = dir.join("session.json");
556
557        // Write initial session with PID on port 9222
558        let launch = SessionData {
559            ws_url: "ws://127.0.0.1:9222/devtools/browser/bbb".into(),
560            port: 9222,
561            pid: Some(99999),
562            active_tab_id: None,
563            timestamp: "2026-02-15T00:00:00Z".into(),
564            last_reconnect_at: None,
565            reconnect_count: 0,
566        };
567        write_session_to(&path, &launch).unwrap();
568
569        // Simulate auto-discover on DIFFERENT port (pid: None)
570        let pid = resolve_pid(&path, None, 9333);
571        assert_eq!(pid, None, "PID should NOT be carried from a different port");
572
573        let _ = std::fs::remove_dir_all(&dir);
574    }
575
576    #[test]
577    fn pid_not_injected_when_no_prior_session() {
578        let dir = std::env::temp_dir().join("agentchrome-test-pid-noinject");
579        let _ = std::fs::remove_dir_all(&dir);
580        // Do NOT create the session file
581
582        let path = dir.join("session.json");
583        let pid = resolve_pid(&path, None, 9222);
584        assert_eq!(
585            pid, None,
586            "No PID should be injected when no prior session exists"
587        );
588
589        let _ = std::fs::remove_dir_all(&dir);
590    }
591
592    #[test]
593    fn incoming_pid_takes_priority_over_existing() {
594        let dir = std::env::temp_dir().join("agentchrome-test-pid-priority");
595        let _ = std::fs::remove_dir_all(&dir);
596        let path = dir.join("session.json");
597
598        // Write existing session with PID
599        let existing = SessionData {
600            ws_url: "ws://127.0.0.1:9222/devtools/browser/ccc".into(),
601            port: 9222,
602            pid: Some(11111),
603            active_tab_id: None,
604            timestamp: "2026-02-15T00:00:00Z".into(),
605            last_reconnect_at: None,
606            reconnect_count: 0,
607        };
608        write_session_to(&path, &existing).unwrap();
609
610        // Incoming ConnectionInfo has its own PID (e.g. new --launch)
611        let pid = resolve_pid(&path, Some(22222), 9222);
612        assert_eq!(pid, Some(22222), "Incoming PID should take priority");
613
614        let _ = std::fs::remove_dir_all(&dir);
615    }
616
617    #[test]
618    fn write_read_round_trip_with_active_tab_id() {
619        let dir = std::env::temp_dir().join("agentchrome-test-session-active-tab");
620        let _ = std::fs::remove_dir_all(&dir);
621        let path = dir.join("session.json");
622
623        let data = SessionData {
624            ws_url: "ws://127.0.0.1:9222/devtools/browser/tab".into(),
625            port: 9222,
626            pid: Some(1234),
627            active_tab_id: Some("ABCDEF123456".into()),
628            timestamp: "2026-02-17T12:00:00Z".into(),
629            last_reconnect_at: None,
630            reconnect_count: 0,
631        };
632
633        write_session_to(&path, &data).unwrap();
634        let read = read_session_from(&path).unwrap().unwrap();
635
636        assert_eq!(read.active_tab_id, Some("ABCDEF123456".into()));
637
638        let _ = std::fs::remove_dir_all(&dir);
639    }
640
641    #[test]
642    fn active_tab_id_skipped_when_none() {
643        let dir = std::env::temp_dir().join("agentchrome-test-session-no-active-tab");
644        let _ = std::fs::remove_dir_all(&dir);
645        let path = dir.join("session.json");
646
647        let data = SessionData {
648            ws_url: "ws://127.0.0.1:9222/devtools/browser/tab".into(),
649            port: 9222,
650            pid: None,
651            active_tab_id: None,
652            timestamp: "2026-02-17T12:00:00Z".into(),
653            last_reconnect_at: None,
654            reconnect_count: 0,
655        };
656
657        write_session_to(&path, &data).unwrap();
658        let contents = std::fs::read_to_string(&path).unwrap();
659        assert!(
660            !contents.contains("active_tab_id"),
661            "active_tab_id should be skipped when None"
662        );
663
664        let _ = std::fs::remove_dir_all(&dir);
665    }
666
667    #[test]
668    fn old_session_without_active_tab_id_deserializes() {
669        let dir = std::env::temp_dir().join("agentchrome-test-session-compat");
670        let _ = std::fs::remove_dir_all(&dir);
671        std::fs::create_dir_all(&dir).unwrap();
672        let path = dir.join("session.json");
673
674        // Simulate an old session file that doesn't have active_tab_id
675        let old_json = r#"{
676            "ws_url": "ws://127.0.0.1:9222/devtools/browser/old",
677            "port": 9222,
678            "pid": 5678,
679            "timestamp": "2026-02-17T12:00:00Z"
680        }"#;
681        std::fs::write(&path, old_json).unwrap();
682
683        let read = read_session_from(&path).unwrap().unwrap();
684        assert_eq!(read.active_tab_id, None);
685        assert_eq!(read.pid, Some(5678));
686        // Legacy files without reconnect telemetry deserialize with defaults.
687        assert_eq!(read.last_reconnect_at, None);
688        assert_eq!(read.reconnect_count, 0);
689
690        let _ = std::fs::remove_dir_all(&dir);
691    }
692
693    #[test]
694    fn legacy_session_without_reconnect_fields_deserializes() {
695        let dir = std::env::temp_dir().join("agentchrome-test-session-legacy-185");
696        let _ = std::fs::remove_dir_all(&dir);
697        std::fs::create_dir_all(&dir).unwrap();
698        let path = dir.join("session.json");
699
700        let legacy_json = r#"{
701            "ws_url": "ws://127.0.0.1:9222/devtools/browser/legacy",
702            "port": 9222,
703            "pid": 4242,
704            "active_tab_id": "TAB1",
705            "timestamp": "2026-04-18T00:00:00Z"
706        }"#;
707        std::fs::write(&path, legacy_json).unwrap();
708
709        let read = read_session_from(&path).unwrap().unwrap();
710        assert_eq!(read.pid, Some(4242));
711        assert_eq!(read.active_tab_id.as_deref(), Some("TAB1"));
712        assert_eq!(read.last_reconnect_at, None);
713        assert_eq!(read.reconnect_count, 0);
714
715        let _ = std::fs::remove_dir_all(&dir);
716    }
717
718    #[test]
719    fn rewrite_preserving_keeps_pid_and_bumps_count() {
720        let dir = std::env::temp_dir().join("agentchrome-test-rewrite-preserving");
721        let _ = std::fs::remove_dir_all(&dir);
722        let path = dir.join("session.json");
723
724        let original = SessionData {
725            ws_url: "ws://127.0.0.1:9222/devtools/browser/OLD".into(),
726            port: 9222,
727            pid: Some(12_345),
728            active_tab_id: Some("TAB-A".into()),
729            timestamp: "2026-04-18T00:00:00Z".into(),
730            last_reconnect_at: None,
731            reconnect_count: 2,
732        };
733        write_session_to(&path, &original).unwrap();
734
735        let updated = rewrite_preserving_to(
736            &path,
737            &original,
738            "ws://127.0.0.1:9222/devtools/browser/NEW".into(),
739        )
740        .unwrap();
741
742        assert_eq!(updated.ws_url, "ws://127.0.0.1:9222/devtools/browser/NEW");
743        assert_eq!(updated.port, 9222);
744        assert_eq!(updated.pid, Some(12_345));
745        assert_eq!(updated.active_tab_id.as_deref(), Some("TAB-A"));
746        assert_eq!(updated.reconnect_count, 3);
747        assert!(updated.last_reconnect_at.is_some());
748
749        // The persisted file matches the returned record
750        let on_disk = read_session_from(&path).unwrap().unwrap();
751        assert_eq!(on_disk.ws_url, updated.ws_url);
752        assert_eq!(on_disk.pid, Some(12_345));
753        assert_eq!(on_disk.reconnect_count, 3);
754
755        let _ = std::fs::remove_dir_all(&dir);
756    }
757
758    #[test]
759    fn session_error_display() {
760        let diag = "HOME env var is unset or invalid".to_string();
761        assert_eq!(
762            SessionError::NoHomeDir(diag.clone()).to_string(),
763            format!("could not determine home directory ({diag})")
764        );
765        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
766        assert_eq!(
767            SessionError::Io(io_err).to_string(),
768            "session file error: denied"
769        );
770        assert_eq!(
771            SessionError::InvalidFormat("bad json".into()).to_string(),
772            "invalid session file: bad json"
773        );
774    }
775
776    #[test]
777    fn windows_home_chain_prefers_userprofile() {
778        let env = |k: &str| match k {
779            "USERPROFILE" => Some("C:\\Users\\rich".to_string()),
780            "HOMEDRIVE" => Some("C:".to_string()),
781            "HOMEPATH" => Some("\\Users\\other".to_string()),
782            _ => None,
783        };
784        let home = windows_home_chain(&env).unwrap();
785        assert_eq!(home, PathBuf::from("C:\\Users\\rich"));
786    }
787
788    #[test]
789    fn windows_home_chain_falls_back_to_homedrive_homepath() {
790        let env = |k: &str| match k {
791            "HOMEDRIVE" => Some("D:".to_string()),
792            "HOMEPATH" => Some("\\Users\\fallback".to_string()),
793            _ => None,
794        };
795        let home = windows_home_chain(&env).unwrap();
796        assert_eq!(home, PathBuf::from("D:\\Users\\fallback"));
797    }
798
799    #[test]
800    fn windows_home_chain_reports_diagnostic_when_unset() {
801        let env = |_k: &str| None;
802        let err = windows_home_chain(&env).unwrap_err();
803        let msg = err.to_string();
804        assert!(
805            msg.contains("USERPROFILE"),
806            "diagnostic must name USERPROFILE: {msg}"
807        );
808        assert!(
809            msg.contains("HOMEDRIVE"),
810            "diagnostic must name HOMEDRIVE: {msg}"
811        );
812        assert!(
813            msg.contains("HOMEPATH"),
814            "diagnostic must name HOMEPATH: {msg}"
815        );
816    }
817
818    #[test]
819    fn read_invalid_json_error_includes_path() {
820        let dir = std::env::temp_dir().join("agentchrome-test-read-err-path");
821        let _ = std::fs::remove_dir_all(&dir);
822        std::fs::create_dir_all(&dir).unwrap();
823        let path = dir.join("session.json");
824        std::fs::write(&path, "{ not json").unwrap();
825
826        let err = read_session_from(&path).unwrap_err();
827        let msg = err.to_string();
828        assert!(
829            msg.contains(&path.display().to_string()),
830            "parse error must include resolved file path: {msg}"
831        );
832
833        let _ = std::fs::remove_dir_all(&dir);
834    }
835
836    #[test]
837    fn write_read_roundtrip_with_non_ascii_and_spaces_in_path() {
838        let dir = std::env::temp_dir().join("agentchrome test Björn O'Malley");
839        let _ = std::fs::remove_dir_all(&dir);
840        let path = dir.join("session.json");
841
842        let data = SessionData {
843            ws_url: "ws://127.0.0.1:9222/devtools/browser/unicode".into(),
844            port: 9222,
845            pid: Some(4242),
846            active_tab_id: Some("ünícødé-tab".into()),
847            timestamp: "2026-04-21T00:00:00Z".into(),
848            last_reconnect_at: None,
849            reconnect_count: 0,
850        };
851
852        write_session_to(&path, &data).unwrap();
853        let read = read_session_from(&path).unwrap().unwrap();
854        assert_eq!(read.ws_url, data.ws_url);
855        assert_eq!(read.active_tab_id.as_deref(), Some("ünícødé-tab"));
856
857        let tmp = path.with_extension("json.tmp");
858        assert!(
859            !tmp.exists(),
860            "temp file must not remain after atomic write"
861        );
862
863        let _ = std::fs::remove_dir_all(&dir);
864    }
865
866    #[test]
867    fn write_recovers_when_final_path_has_preexisting_file() {
868        // Sanity: rename over an existing file is supported on all platforms we
869        // target. This guards against a regression where the retry loop would
870        // incorrectly classify EEXIST as a transient error.
871        let dir = std::env::temp_dir().join("agentchrome-test-write-over-existing");
872        let _ = std::fs::remove_dir_all(&dir);
873        std::fs::create_dir_all(&dir).unwrap();
874        let path = dir.join("session.json");
875        std::fs::write(&path, "stale contents").unwrap();
876
877        let data = SessionData {
878            ws_url: "ws://127.0.0.1:9222/devtools/browser/over".into(),
879            port: 9222,
880            pid: None,
881            active_tab_id: None,
882            timestamp: "2026-04-21T00:00:00Z".into(),
883            last_reconnect_at: None,
884            reconnect_count: 0,
885        };
886        write_session_to(&path, &data).unwrap();
887        let read = read_session_from(&path).unwrap().unwrap();
888        assert_eq!(read.ws_url, data.ws_url);
889
890        let _ = std::fs::remove_dir_all(&dir);
891    }
892}