use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::process; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionInfo { pub name: String, pub port: u16, pub pid: u32, } pub fn sessions_dir(settings_path: &Path) -> PathBuf { settings_path .parent() .map(|parent| parent.join("sessions")) .unwrap_or_else(|| PathBuf::from("sessions")) } fn session_file_path(dir: &Path, name: &str) -> PathBuf { dir.join(format!("{name}.json")) } #[cfg(unix)] fn is_pid_alive(pid: u32) -> bool { process::Command::new("kill") .args(["-0", &pid.to_string()]) .stdout(process::Stdio::null()) .stderr(process::Stdio::null()) .status() .map(|status| status.success()) .unwrap_or(true) } #[cfg(not(unix))] fn is_pid_alive(_pid: u32) -> bool { true } pub fn cleanup_stale(dir: &Path) { let Ok(entries) = fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("json") { continue; } let Ok(contents) = fs::read_to_string(&path) else { continue; }; let Ok(info) = serde_json::from_str::(&contents) else { continue; }; if !is_pid_alive(info.pid) { let _ = fs::remove_file(&path); } } } pub fn generate_name() -> String { petname::petname(2, "-").unwrap_or_else(|| format!("session-{}", process::id())) } pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Result { let dir = sessions_dir(settings_path); fs::create_dir_all(&dir)?; cleanup_stale(&dir); let info = SessionInfo { name: name.to_string(), port, pid: process::id(), }; let path = session_file_path(&dir, name); fs::write(&path, serde_json::to_string_pretty(&info)?)?; Ok(path) } pub fn resolve_session(settings_path: &Path, name: &str) -> Option { let dir = sessions_dir(settings_path); let contents = fs::read_to_string(session_file_path(&dir, name)).ok()?; let info: SessionInfo = serde_json::from_str(&contents).ok()?; is_pid_alive(info.pid).then_some(info) } pub fn list_sessions(dir: &Path) -> Vec { let Ok(entries) = fs::read_dir(dir) else { return Vec::new(); }; let mut sessions: Vec = entries .flatten() .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json")) .filter_map(|entry| fs::read_to_string(entry.path()).ok()) .filter_map(|contents| serde_json::from_str(&contents).ok()) .collect(); sessions.sort_by(|a, b| a.name.cmp(&b.name)); sessions } #[cfg(test)] mod tests { use super::*; #[test] fn register_session_writes_correct_port() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let path = register_session(&settings_path, "foo", 4242).unwrap(); assert!(path.exists()); let info = resolve_session(&settings_path, "foo").unwrap(); assert_eq!(info.port, 4242); assert_eq!(info.name, "foo"); assert_eq!(info.pid, process::id()); } #[test] fn resolve_session_returns_none_for_unknown_name() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); assert!(resolve_session(&settings_path, "does-not-exist").is_none()); } #[test] fn stale_session_does_not_block_reregistration_under_same_name() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let sessions_dir_path = sessions_dir(&settings_path); fs::create_dir_all(&sessions_dir_path).unwrap(); let stale = SessionInfo { name: "foo".to_string(), port: 1111, pid: 999_999 }; fs::write( session_file_path(&sessions_dir_path, "foo"), serde_json::to_string(&stale).unwrap(), ) .unwrap(); register_session(&settings_path, "foo", 5555).unwrap(); let info = resolve_session(&settings_path, "foo").unwrap(); assert_eq!(info.port, 5555); assert_eq!(info.pid, process::id()); } #[test] fn list_sessions_returns_all_entries_sorted_by_name() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); register_session(&settings_path, "zeta", 1).unwrap(); register_session(&settings_path, "alpha", 2).unwrap(); let sessions = list_sessions(&sessions_dir(&settings_path)); let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["alpha", "zeta"]); } }