use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub type SessionKey = String;
pub fn make_session_key(session_id: &str, tty: &str) -> SessionKey {
format!("{}:{}", session_id, tty)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum HookSessionStatus {
Running,
WaitingInput,
Stopped,
#[default]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookSession {
pub session_id: String,
pub cwd: String,
pub tty: String,
pub status: HookSessionStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_event: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LiveSessionFile {
pub version: u8,
pub sessions: HashMap<SessionKey, HookSession>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Error)]
pub enum HookStateError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("No home directory found")]
NoHome,
}
impl LiveSessionFile {
pub fn default_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".ccboard").join("live-sessions.json"))
}
pub fn lock_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".ccboard").join("live-sessions.lock"))
}
pub fn load(path: &Path) -> Result<Self, HookStateError> {
if !path.exists() {
return Ok(Self {
version: 1,
..Default::default()
});
}
let data = std::fs::read(path)?;
let mut file: Self = serde_json::from_slice(&data)?;
if file.version == 0 {
file.version = 1;
}
Ok(file)
}
pub fn save(&self, path: &Path) -> Result<(), HookStateError> {
let tmp_path = path.with_extension("tmp");
let data = serde_json::to_vec_pretty(self)?;
std::fs::write(&tmp_path, &data)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn prune_stopped(&mut self, max_age: std::time::Duration) {
let cutoff =
Utc::now() - Duration::from_std(max_age).unwrap_or_else(|_| Duration::minutes(30));
self.sessions
.retain(|_, s| s.status != HookSessionStatus::Stopped || s.updated_at >= cutoff);
}
pub fn upsert(
&mut self,
key: SessionKey,
session_id: String,
cwd: String,
tty: String,
new_status: HookSessionStatus,
event_name: String,
) {
let now = Utc::now();
let effective_status = if new_status == HookSessionStatus::Running
&& self
.sessions
.get(&key)
.map(|s| s.status == HookSessionStatus::Stopped)
.unwrap_or(false)
{
HookSessionStatus::Running
} else {
new_status
};
if let Some(existing) = self.sessions.get_mut(&key) {
existing.status = effective_status;
existing.updated_at = now;
existing.last_event = event_name;
} else {
self.sessions.insert(
key,
HookSession {
session_id,
cwd,
tty,
status: effective_status,
created_at: now,
updated_at: now,
last_event: event_name,
},
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_prune_stopped_removes_old() {
let mut file = LiveSessionFile {
version: 1,
..Default::default()
};
let old_time = Utc::now() - Duration::from_secs(31 * 60); file.sessions.insert(
"s1:tty1".to_string(),
HookSession {
session_id: "s1".to_string(),
cwd: "/tmp".to_string(),
tty: "tty1".to_string(),
status: HookSessionStatus::Stopped,
created_at: old_time,
updated_at: old_time,
last_event: "Stop".to_string(),
},
);
file.sessions.insert(
"s2:tty2".to_string(),
HookSession {
session_id: "s2".to_string(),
cwd: "/tmp".to_string(),
tty: "tty2".to_string(),
status: HookSessionStatus::Running,
created_at: old_time,
updated_at: old_time,
last_event: "PreToolUse".to_string(),
},
);
file.prune_stopped(Duration::from_secs(30 * 60));
assert!(!file.sessions.contains_key("s1:tty1"));
assert!(file.sessions.contains_key("s2:tty2"));
}
#[test]
fn test_upsert_revives_stopped() {
let mut file = LiveSessionFile {
version: 1,
..Default::default()
};
let key = "s1:tty1".to_string();
let old_time = Utc::now() - chrono::Duration::seconds(5);
file.sessions.insert(
key.clone(),
HookSession {
session_id: "s1".to_string(),
cwd: "/tmp".to_string(),
tty: "tty1".to_string(),
status: HookSessionStatus::Stopped,
created_at: old_time,
updated_at: old_time,
last_event: "Stop".to_string(),
},
);
file.upsert(
key.clone(),
"s1".to_string(),
"/tmp".to_string(),
"tty1".to_string(),
HookSessionStatus::Running,
"UserPromptSubmit".to_string(),
);
assert_eq!(file.sessions[&key].status, HookSessionStatus::Running);
}
#[test]
fn test_make_session_key() {
assert_eq!(
make_session_key("abc-123", "/dev/ttys001"),
"abc-123:/dev/ttys001"
);
}
}