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}
17
18#[derive(Debug)]
20pub enum SessionError {
21 NoHomeDir,
23 Io(std::io::Error),
25 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
65pub 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
88pub fn write_session(data: &SessionData) -> Result<(), SessionError> {
98 let path = session_file_path()?;
99 write_session_to(&path, data)
100}
101
102pub 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 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
134pub fn read_session() -> Result<Option<SessionData>, SessionError> {
143 let path = session_file_path()?;
144 read_session_from(&path)
145}
146
147pub 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
165pub fn delete_session() -> Result<(), SessionError> {
171 let path = session_file_path()?;
172 delete_session_from(&path)
173}
174
175pub 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#[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 let mut days = (secs / 86_400) as i64;
217 days += 719_468; let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
219 let day_of_era = (days - era * 146_097) as u32; 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); 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 };
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 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 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 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 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 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 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 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 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 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 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 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}