Skip to main content

keep_running/
session.rs

1use anyhow::{Context, Result};
2use rand::seq::SliceRandom;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6use std::time::SystemTime;
7
8const ADJECTIVES: &[&str] = &[
9    "fuzzy", "quick", "lazy", "happy", "sleepy", "brave", "calm", "eager", "gentle", "kind",
10    "lively", "merry", "nice", "proud", "silly", "witty", "bold", "cool", "dapper", "fancy",
11    "jolly", "keen", "lucky", "noble",
12];
13
14const NOUNS: &[&str] = &[
15    "penguin", "dolphin", "falcon", "tiger", "panda", "koala", "otter", "fox", "owl", "bear",
16    "wolf", "eagle", "shark", "whale", "raven", "lynx", "badger", "gecko", "lemur", "moose",
17    "orca", "quail", "sloth", "zebra",
18];
19
20/// Session metadata stored on disk
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SessionInfo {
23    pub name: String,
24    pub command: Vec<String>,
25    pub pid: u32,
26    pub created_at: u64,
27    pub socket_path: String,
28}
29
30/// Get the directory for session metadata
31pub fn sessions_dir() -> Result<PathBuf> {
32    // Check for environment variable override (for subprocess tests)
33    if let Ok(env_path) = std::env::var("KEEP_RUNNING_SESSION_DIR") {
34        let path = PathBuf::from(env_path);
35        fs::create_dir_all(&path)?;
36        return Ok(path);
37    }
38
39    let config_dir = dirs::config_dir()
40        .context("Could not determine config directory")?
41        .join("keep-running")
42        .join("sessions");
43    fs::create_dir_all(&config_dir)?;
44    Ok(config_dir)
45}
46
47/// Get the directory for session sockets
48pub fn sockets_dir() -> Result<PathBuf> {
49    // Check for environment variable override (for subprocess tests)
50    if let Ok(env_path) = std::env::var("KEEP_RUNNING_SOCKET_DIR") {
51        let path = PathBuf::from(env_path);
52        fs::create_dir_all(&path)?;
53        return Ok(path);
54    }
55
56    let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
57        .map(PathBuf::from)
58        .unwrap_or_else(|_| {
59            let uid = unsafe { libc::getuid() };
60            PathBuf::from(format!("/tmp/keep-running-{}", uid))
61        });
62    let socket_dir = runtime_dir.join("keep-running");
63    fs::create_dir_all(&socket_dir)?;
64    Ok(socket_dir)
65}
66
67/// Generate a human-readable session name
68pub fn generate_name() -> String {
69    let mut rng = rand::thread_rng();
70    let adj = ADJECTIVES.choose(&mut rng).unwrap();
71    let noun = NOUNS.choose(&mut rng).unwrap();
72    format!("{}-{}", adj, noun)
73}
74
75/// Generate a unique session name (avoid collisions)
76pub fn generate_unique_name() -> Result<String> {
77    let existing = list_sessions()?;
78    let existing_names: std::collections::HashSet<_> =
79        existing.iter().map(|s| s.name.as_str()).collect();
80
81    for _ in 0..100 {
82        let name = generate_name();
83        if !existing_names.contains(name.as_str()) {
84            return Ok(name);
85        }
86    }
87
88    // Fallback: add random suffix
89    let name = format!("{}-{}", generate_name(), rand::random::<u16>());
90    Ok(name)
91}
92
93/// Save session info to disk
94pub fn save_session(info: &SessionInfo) -> Result<()> {
95    let path = sessions_dir()?.join(format!("{}.json", info.name));
96    let json = serde_json::to_string_pretty(info)?;
97    fs::write(&path, json)?;
98    Ok(())
99}
100
101/// Load session info from disk
102pub fn load_session(name: &str) -> Result<Option<SessionInfo>> {
103    let path = sessions_dir()?.join(format!("{}.json", name));
104    if !path.exists() {
105        return Ok(None);
106    }
107    let json = fs::read_to_string(&path)?;
108    let info: SessionInfo = serde_json::from_str(&json)?;
109    Ok(Some(info))
110}
111
112/// Remove session info from disk
113pub fn remove_session(name: &str) -> Result<()> {
114    let path = sessions_dir()?.join(format!("{}.json", name));
115    if path.exists() {
116        fs::remove_file(&path)?;
117    }
118
119    // Also try to remove socket
120    if let Ok(sockets) = sockets_dir() {
121        let socket_path = sockets.join(format!("{}.sock", name));
122        let _ = fs::remove_file(&socket_path);
123    }
124
125    Ok(())
126}
127
128/// List all sessions (cleaning up dead ones)
129pub fn list_sessions() -> Result<Vec<SessionInfo>> {
130    let dir = sessions_dir()?;
131    let mut sessions = Vec::new();
132    let mut dead_sessions = Vec::new();
133
134    for entry in fs::read_dir(&dir)? {
135        let entry = entry?;
136        let path = entry.path();
137
138        if path.extension().map(|e| e == "json").unwrap_or(false) {
139            if let Ok(json) = fs::read_to_string(&path) {
140                if let Ok(info) = serde_json::from_str::<SessionInfo>(&json) {
141                    // Check if daemon is still alive
142                    if is_process_alive(info.pid) {
143                        sessions.push(info);
144                    } else {
145                        dead_sessions.push(info.name.clone());
146                    }
147                }
148            }
149        }
150    }
151
152    // Clean up dead sessions
153    for name in dead_sessions {
154        let _ = remove_session(&name);
155    }
156
157    sessions.sort_by_key(|s| s.created_at);
158    Ok(sessions)
159}
160
161/// Check if a process is still running
162fn is_process_alive(pid: u32) -> bool {
163    unsafe { libc::kill(pid as i32, 0) == 0 }
164}
165
166/// Find a session by name (supports prefix matching)
167pub fn find_session(query: &str) -> Result<Option<SessionInfo>> {
168    let sessions = list_sessions()?;
169
170    // Exact match first
171    if let Some(session) = sessions.iter().find(|s| s.name == query) {
172        return Ok(Some(session.clone()));
173    }
174
175    // Prefix match
176    let matches: Vec<_> = sessions
177        .iter()
178        .filter(|s| s.name.starts_with(query))
179        .collect();
180
181    match matches.len() {
182        0 => Ok(None),
183        1 => Ok(Some(matches[0].clone())),
184        _ => {
185            let names: Vec<_> = matches.iter().map(|s| s.name.as_str()).collect();
186            anyhow::bail!(
187                "Ambiguous session name '{}', matches: {}",
188                query,
189                names.join(", ")
190            );
191        }
192    }
193}
194
195/// Get socket path for a session
196pub fn socket_path(name: &str) -> Result<PathBuf> {
197    Ok(sockets_dir()?.join(format!("{}.sock", name)))
198}
199
200/// Get current timestamp as seconds since epoch
201pub fn timestamp() -> u64 {
202    SystemTime::now()
203        .duration_since(SystemTime::UNIX_EPOCH)
204        .map(|d| d.as_secs())
205        .unwrap_or(0)
206}