1use std::fmt;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6#[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 #[serde(skip_serializing_if = "Option::is_none", default)]
18 pub last_reconnect_at: Option<String>,
19 #[serde(default)]
21 pub reconnect_count: u32,
22}
23
24#[derive(Debug)]
26pub enum SessionError {
27 NoHomeDir(String),
31 Io(std::io::Error),
33 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
76pub 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#[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
124pub fn write_session(data: &SessionData) -> Result<(), SessionError> {
134 let path = session_file_path()?;
135 write_session_to(&path, data)
136}
137
138pub 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
167const RENAME_RETRIES: u32 = 5;
171const RENAME_RETRY_DELAY_MS: u64 = 10;
174
175fn 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 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
219fn 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
228pub fn read_session() -> Result<Option<SessionData>, SessionError> {
237 let path = session_file_path()?;
238 read_session_from(&path)
239}
240
241pub 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
259pub fn delete_session() -> Result<(), SessionError> {
265 let path = session_file_path()?;
266 delete_session_from(&path)
267}
268
269pub 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
282pub 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
301pub 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#[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 let mut days = (secs / 86_400) as i64;
361 days += 719_468; let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
363 let day_of_era = (days - era * 146_097) as u32; 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); let mp = (5 * day_of_year + 2) / 153; let d = day_of_year - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}