use anyhow::{Context, Result};
use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::time::SystemTime;
const ADJECTIVES: &[&str] = &[
"fuzzy", "quick", "lazy", "happy", "sleepy", "brave", "calm", "eager", "gentle", "kind",
"lively", "merry", "nice", "proud", "silly", "witty", "bold", "cool", "dapper", "fancy",
"jolly", "keen", "lucky", "noble",
];
const NOUNS: &[&str] = &[
"penguin", "dolphin", "falcon", "tiger", "panda", "koala", "otter", "fox", "owl", "bear",
"wolf", "eagle", "shark", "whale", "raven", "lynx", "badger", "gecko", "lemur", "moose",
"orca", "quail", "sloth", "zebra",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub name: String,
pub command: Vec<String>,
pub pid: u32,
pub created_at: u64,
pub socket_path: String,
}
pub fn sessions_dir() -> Result<PathBuf> {
if let Ok(env_path) = std::env::var("KEEP_RUNNING_SESSION_DIR") {
let path = PathBuf::from(env_path);
fs::create_dir_all(&path)?;
return Ok(path);
}
let config_dir = dirs::config_dir()
.context("Could not determine config directory")?
.join("keep-running")
.join("sessions");
fs::create_dir_all(&config_dir)?;
Ok(config_dir)
}
pub fn sockets_dir() -> Result<PathBuf> {
if let Ok(env_path) = std::env::var("KEEP_RUNNING_SOCKET_DIR") {
let path = PathBuf::from(env_path);
fs::create_dir_all(&path)?;
return Ok(path);
}
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let uid = unsafe { libc::getuid() };
PathBuf::from(format!("/tmp/keep-running-{}", uid))
});
let socket_dir = runtime_dir.join("keep-running");
fs::create_dir_all(&socket_dir)?;
Ok(socket_dir)
}
pub fn generate_name() -> String {
let mut rng = rand::thread_rng();
let adj = ADJECTIVES.choose(&mut rng).unwrap();
let noun = NOUNS.choose(&mut rng).unwrap();
format!("{}-{}", adj, noun)
}
pub fn generate_unique_name() -> Result<String> {
let existing = list_sessions()?;
let existing_names: std::collections::HashSet<_> =
existing.iter().map(|s| s.name.as_str()).collect();
for _ in 0..100 {
let name = generate_name();
if !existing_names.contains(name.as_str()) {
return Ok(name);
}
}
let name = format!("{}-{}", generate_name(), rand::random::<u16>());
Ok(name)
}
pub fn save_session(info: &SessionInfo) -> Result<()> {
let path = sessions_dir()?.join(format!("{}.json", info.name));
let json = serde_json::to_string_pretty(info)?;
fs::write(&path, json)?;
Ok(())
}
pub fn load_session(name: &str) -> Result<Option<SessionInfo>> {
let path = sessions_dir()?.join(format!("{}.json", name));
if !path.exists() {
return Ok(None);
}
let json = fs::read_to_string(&path)?;
let info: SessionInfo = serde_json::from_str(&json)?;
Ok(Some(info))
}
pub fn remove_session(name: &str) -> Result<()> {
let path = sessions_dir()?.join(format!("{}.json", name));
if path.exists() {
fs::remove_file(&path)?;
}
if let Ok(sockets) = sockets_dir() {
let socket_path = sockets.join(format!("{}.sock", name));
let _ = fs::remove_file(&socket_path);
}
Ok(())
}
pub fn list_sessions() -> Result<Vec<SessionInfo>> {
let dir = sessions_dir()?;
let mut sessions = Vec::new();
let mut dead_sessions = Vec::new();
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(json) = fs::read_to_string(&path) {
if let Ok(info) = serde_json::from_str::<SessionInfo>(&json) {
if is_process_alive(info.pid) {
sessions.push(info);
} else {
dead_sessions.push(info.name.clone());
}
}
}
}
}
for name in dead_sessions {
let _ = remove_session(&name);
}
sessions.sort_by_key(|s| s.created_at);
Ok(sessions)
}
fn is_process_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
}
pub fn find_session(query: &str) -> Result<Option<SessionInfo>> {
let sessions = list_sessions()?;
if let Some(session) = sessions.iter().find(|s| s.name == query) {
return Ok(Some(session.clone()));
}
let matches: Vec<_> = sessions
.iter()
.filter(|s| s.name.starts_with(query))
.collect();
match matches.len() {
0 => Ok(None),
1 => Ok(Some(matches[0].clone())),
_ => {
let names: Vec<_> = matches.iter().map(|s| s.name.as_str()).collect();
anyhow::bail!(
"Ambiguous session name '{}', matches: {}",
query,
names.join(", ")
);
}
}
}
pub fn socket_path(name: &str) -> Result<PathBuf> {
Ok(sockets_dir()?.join(format!("{}.sock", name)))
}
pub fn timestamp() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}