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 pub timestamp: String,
14}
15
16#[derive(Debug)]
18pub enum SessionError {
19 NoHomeDir,
21 Io(std::io::Error),
23 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
63pub 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
86pub fn write_session(data: &SessionData) -> Result<(), SessionError> {
96 let path = session_file_path()?;
97 write_session_to(&path, data)
98}
99
100pub 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 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
132pub fn read_session() -> Result<Option<SessionData>, SessionError> {
141 let path = session_file_path()?;
142 read_session_from(&path)
143}
144
145pub 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
163pub fn delete_session() -> Result<(), SessionError> {
169 let path = session_file_path()?;
170 delete_session_from(&path)
171}
172
173pub 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#[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 let mut days = (secs / 86_400) as i64;
215 days += 719_468; let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
217 let day_of_era = (days - era * 146_097) as u32; 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); 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 };
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 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 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 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 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 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 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 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 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 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 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}