use std::{ env, fs, io::{self, Write}, path::{Path, PathBuf}, }; use serde::{Deserialize, Serialize}; use crate::config::CONFIG_FILENAME; pub const INSTANCE_DIR_NAME: &str = ".vibebox"; pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox"; pub const GLOBAL_DIR_NAME: &str = ".vibebox"; pub const INSTANCE_FILENAME: &str = "instance.toml"; pub const SESSION_TOML_SUFFIX: &str = ".toml"; const SESSIONS_DIR_NAME: &str = "sessions"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionRecord { pub directory: PathBuf, pub id: String, pub last_active: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct SessionEntry { pub directory: PathBuf, pub id: String, } #[derive(Debug, Default, Deserialize)] struct InstanceMetadata { #[serde(default)] id: Option, #[serde(default)] last_active: Option, } #[derive(Debug)] pub struct SessionManager { sessions_dir: PathBuf, } #[derive(Debug, thiserror::Error)] pub enum SessionError { #[error("HOME environment variable is not set")] MissingHome, #[error("Session directory must be absolute: {0}")] NonAbsoluteDirectory(PathBuf), #[error("Session directory does not exist: {0}")] MissingDirectory(PathBuf), #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] TomlDe(#[from] toml::de::Error), #[error(transparent)] TomlSer(#[from] toml::ser::Error), } impl SessionManager { pub fn new() -> Result { let home = env::var_os("HOME").ok_or(SessionError::MissingHome)?; Ok(Self::with_global_dir( PathBuf::from(home).join(GLOBAL_DIR_NAME), )) } pub fn with_global_dir(global_dir: PathBuf) -> Self { let sessions_dir = global_dir.join(SESSIONS_DIR_NAME); Self { sessions_dir } } pub fn index_path(&self) -> &Path { &self.sessions_dir } pub fn update_global_sessions(&self, directory: &Path) -> Result, SessionError> { let directory = self.normalize_directory(directory)?; fs::create_dir_all(&self.sessions_dir)?; let (mut sessions, removed) = self.prune_stale_sessions()?; let has_config = is_vibebox_dir(&directory); let mut added = false; if has_config { let meta = read_instance_metadata(&directory)?; if let Some(id) = meta.id { let record = SessionEntry { directory: directory.clone(), id: id.clone(), }; self.write_session_record(&record)?; if let Some(existing) = sessions.iter_mut().find(|s| s.id == id) { *existing = record; } else { sessions.push(record); } added = true; } else { tracing::warn!( directory = %directory.display(), file = INSTANCE_FILENAME, "missing session id in instance file" ); } } if removed > 0 || added { tracing::info!( path = %self.sessions_dir.display(), removed, added, entries = sessions.len(), "updated global sessions" ); } else { tracing::debug!( path = %self.sessions_dir.display(), entries = sessions.len(), has_config, "global sessions unchanged" ); } Ok(sessions.into_iter().map(|s| s.directory).collect()) } pub fn list_sessions(&self) -> Result, SessionError> { let (sessions, removed) = self.prune_stale_sessions()?; if removed > 0 { tracing::info!( path = %self.sessions_dir.display(), removed, entries = sessions.len(), "pruned stale sessions" ); } let mut records = Vec::with_capacity(sessions.len()); for session in sessions { let meta = read_instance_metadata(&session.directory)?; records.push(SessionRecord { directory: session.directory, id: session.id, last_active: meta.last_active, }); } Ok(records) } fn normalize_directory(&self, directory: &Path) -> Result { if !directory.is_absolute() { return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf())); } if !directory.exists() { return Err(SessionError::MissingDirectory(directory.to_path_buf())); } Ok(directory.canonicalize()?) } fn session_path_for(&self, id: &str) -> PathBuf { let filename = format!("{id}{SESSION_TOML_SUFFIX}"); self.sessions_dir.join(filename) } fn write_session_record(&self, record: &SessionEntry) -> Result<(), SessionError> { fs::create_dir_all(&self.sessions_dir)?; let path = self.session_path_for(&record.id); let content = toml::to_string_pretty(record)?; atomic_write(&path, content.as_bytes())?; tracing::info!( path = %path.display(), "wrote session record" ); Ok(()) } fn prune_stale_sessions(&self) -> Result<(Vec, usize), SessionError> { if !self.sessions_dir.exists() { return Ok((Vec::new(), 0)); } let mut sessions = Vec::new(); let mut removed = 0usize; for entry in fs::read_dir(&self.sessions_dir)? { let entry = entry?; let path = entry.path(); if !path.is_file() { continue; } let record = read_session_file(&path)?; if !is_vibebox_dir(&record.directory) { let _ = fs::remove_file(&path); removed += 1; continue; } if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { if stem != record.id { let _ = fs::remove_file(&path); removed += 1; continue; } } sessions.push(record); } Ok((sessions, removed)) } } fn is_vibebox_dir(directory: &Path) -> bool { if !directory.is_absolute() { return false; } directory.join(CONFIG_FILENAME).is_file() } fn read_session_file(path: &Path) -> Result { let raw = fs::read_to_string(path)?; let record: SessionEntry = toml::from_str(&raw)?; if record.id.trim().is_empty() { return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into()); } Ok(record) } fn read_instance_metadata(directory: &Path) -> Result { let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME); if !instance_path.exists() { return Ok(InstanceMetadata::default()); } let raw = fs::read_to_string(&instance_path)?; let mut meta: InstanceMetadata = toml::from_str(&raw)?; if let Some(id) = &meta.id { if id.trim().is_empty() { meta.id = None; } } if let Some(last_active) = &meta.last_active { if last_active.trim().is_empty() { meta.last_active = None; } } Ok(meta) } fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> { let Some(parent) = path.parent() else { return Err(io::Error::new( io::ErrorKind::InvalidInput, "path has no parent directory", )); }; fs::create_dir_all(parent)?; let mut temp = tempfile::Builder::new() .prefix(SESSIONS_DIR_NAME) .suffix(SESSION_TOML_SUFFIX) .tempfile_in(parent)?; temp.write_all(content)?; temp.flush()?; temp.persist(path).map_err(|err| err.error)?; Ok(()) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; fn manager(temp: &TempDir) -> SessionManager { SessionManager::with_global_dir(temp.path().join("global")) } fn create_project_dir(temp: &TempDir) -> PathBuf { let dir = temp.path().join("project"); fs::create_dir_all(&dir).unwrap(); dir } fn write_instance(project_dir: &Path, id: &str, last_active: &str) { let instance_dir = project_dir.join(INSTANCE_DIR_NAME); fs::create_dir_all(&instance_dir).unwrap(); let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n"); fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap(); } #[test] fn update_global_sessions_adds_directory() { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); write_instance( &project_dir, "019bf290-cccc-7c23-ba1d-dce7e6d40693", "2026-02-07T05:00:00Z", ); let dirs = mgr.update_global_sessions(&project_dir).unwrap(); assert_eq!(dirs.len(), 1); assert_eq!(dirs[0], project_dir.canonicalize().unwrap()); assert!(mgr.index_path().exists()); let session_path = mgr.index_path().join(format!( "019bf290-cccc-7c23-ba1d-dce7e6d40693{}", SESSION_TOML_SUFFIX )); assert!(session_path.exists()); } #[test] fn update_global_sessions_removes_missing_vibebox_toml() { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); write_instance( &project_dir, "019bf290-cccc-7c23-ba1d-dce7e6d40693", "2026-02-07T05:00:00Z", ); let _ = mgr.update_global_sessions(&project_dir).unwrap(); fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap(); let sessions = mgr.list_sessions().unwrap(); assert!(sessions.is_empty()); let session_path = mgr.index_path().join(format!( "019bf290-cccc-7c23-ba1d-dce7e6d40693{}", SESSION_TOML_SUFFIX )); assert!(!session_path.exists()); } #[test] fn invalid_toml_returns_error() { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); fs::create_dir_all(mgr.index_path()).unwrap(); fs::write( mgr.index_path().join(format!("bad{SESSION_TOML_SUFFIX}")), "not toml", ) .unwrap(); let err = mgr.list_sessions().unwrap_err(); assert!(matches!(err, SessionError::TomlDe(_))); } #[test] fn list_sessions_reads_session_files() { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); write_instance( &project_dir, "019bf290-cccc-7c23-ba1d-dce7e6d40693", "2026-02-07T05:00:00Z", ); let _ = mgr.update_global_sessions(&project_dir).unwrap(); let sessions = mgr.list_sessions().unwrap(); assert_eq!(sessions.len(), 1); assert_eq!(sessions[0].id, "019bf290-cccc-7c23-ba1d-dce7e6d40693"); assert_eq!( sessions[0].last_active.as_deref(), Some("2026-02-07T05:00:00Z") ); } }