@@ -0,0 +1,487 @@
4+ path::{Path, PathBuf},
7+use serde::{Deserialize, Serialize};
8+use time::OffsetDateTime;
11+pub const INSTANCE_DIR_NAME: &str = ".vibebox";
12+pub const GLOBAL_DIR_NAME: &str = ".vibebox";
13+pub const SESSION_INDEX_FILENAME: &str = "sessions.toml";
14+pub const SESSION_TEMP_PREFIX: &str = "sessions";
15+pub const SESSION_TOML_SUFFIX: &str = ".toml";
17+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18+pub struct SessionRecord {
20+ pub directory: PathBuf,
21+ #[serde(with = "time::serde::rfc3339")]
22+ pub last_active: OffsetDateTime,
27+#[derive(Debug, Default, Serialize, Deserialize)]
30+ sessions: Vec<SessionRecord>,
34+pub struct SessionManager {
39+#[derive(Debug, thiserror::Error)]
40+pub enum SessionError {
41+ #[error("HOME environment variable is not set")]
43+ #[error("Session directory must be absolute: {0}")]
44+ NonAbsoluteDirectory(PathBuf),
45+ #[error("Session directory does not exist: {0}")]
46+ MissingDirectory(PathBuf),
47+ #[error("Session already exists for directory: {0}")]
48+ DirectoryAlreadyHasSession(PathBuf),
49+ #[error("Session not found: {0}")]
50+ SessionNotFound(Uuid),
51+ #[error("Ref count underflow for session: {0}")]
52+ RefCountUnderflow(Uuid),
53+ #[error(transparent)]
54+ Io(#[from] std::io::Error),
55+ #[error(transparent)]
56+ TomlDe(#[from] toml::de::Error),
57+ #[error(transparent)]
58+ TomlSer(#[from] toml::ser::Error),
62+ pub fn new() -> Result<Self, SessionError> {
63+ let home = env::var_os("HOME").ok_or(SessionError::MissingHome)?;
64+ Ok(Self::with_global_dir(
65+ PathBuf::from(home).join(GLOBAL_DIR_NAME),
69+ pub fn with_global_dir(global_dir: PathBuf) -> Self {
70+ let index_path = global_dir.join(SESSION_INDEX_FILENAME);
77+ pub fn index_path(&self) -> &Path {
81+ pub fn create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
82+ let directory = self.normalize_directory(directory)?;
83+ let mut index = self.read_index()?;
85+ if index.sessions.iter().any(|s| s.directory == directory) {
86+ return Err(SessionError::DirectoryAlreadyHasSession(directory));
89+ let now = OffsetDateTime::now_utc();
90+ let session = SessionRecord {
92+ directory: directory.clone(),
97+ fs::create_dir_all(self.instance_dir_for(&directory))?;
98+ index.sessions.push(session.clone());
99+ self.write_index(&index)?;
104+ pub fn get_or_create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
105+ let directory = self.normalize_directory(directory)?;
106+ let mut index = self.read_index()?;
108+ if let Some(pos) = index.sessions.iter().position(|s| s.directory == directory) {
110+ .instance_dir_for(&index.sessions[pos].directory)
113+ index.sessions.remove(pos);
115+ let now = OffsetDateTime::now_utc();
116+ let session = &mut index.sessions[pos];
117+ session.ref_count = session.ref_count.saturating_add(1);
118+ session.last_active = now;
119+ let updated = session.clone();
120+ self.write_index(&index)?;
125+ let now = OffsetDateTime::now_utc();
126+ let session = SessionRecord {
128+ directory: directory.clone(),
133+ fs::create_dir_all(self.instance_dir_for(&directory))?;
134+ index.sessions.push(session.clone());
135+ self.write_index(&index)?;
140+ pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
141+ let mut index = self.read_index()?;
142+ let removed = self.remove_orphans(&mut index);
144+ self.write_index(&index)?;
149+ pub fn delete_session(&self, id: Uuid) -> Result<bool, SessionError> {
150+ let mut index = self.read_index()?;
151+ let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
155+ let session = index.sessions.remove(pos);
156+ let instance_dir = self.instance_dir_for(&session.directory);
157+ match fs::remove_dir_all(&instance_dir) {
159+ Err(err) if err.kind() == io::ErrorKind::NotFound => {}
160+ Err(err) => return Err(err.into()),
163+ self.write_index(&index)?;
167+ pub fn bump_last_active(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
168+ let now = OffsetDateTime::now_utc();
169+ self.update_session(id, |session| {
170+ session.last_active = now;
175+ pub fn increment_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
176+ let now = OffsetDateTime::now_utc();
177+ self.update_session(id, |session| {
178+ session.ref_count = session.ref_count.saturating_add(1);
179+ session.last_active = now;
184+ pub fn decrement_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
185+ let now = OffsetDateTime::now_utc();
186+ self.update_session(id, |session| {
187+ if session.ref_count == 0 {
188+ return Err(SessionError::RefCountUnderflow(id));
190+ session.ref_count -= 1;
191+ session.last_active = now;
196+ pub fn cleanup_orphans(&self) -> Result<usize, SessionError> {
197+ let mut index = self.read_index()?;
198+ let removed = self.remove_orphans(&mut index);
200+ self.write_index(&index)?;
205+ fn update_session<F>(&self, id: Uuid, mut update: F) -> Result<SessionRecord, SessionError>
207+ F: FnMut(&mut SessionRecord) -> Result<(), SessionError>,
209+ let mut index = self.read_index()?;
210+ let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
211+ return Err(SessionError::SessionNotFound(id));
215+ .instance_dir_for(&index.sessions[pos].directory)
218+ index.sessions.remove(pos);
219+ self.write_index(&index)?;
220+ return Err(SessionError::SessionNotFound(id));
223+ update(&mut index.sessions[pos])?;
224+ let updated = index.sessions[pos].clone();
225+ self.write_index(&index)?;
229+ fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
230+ if !directory.is_absolute() {
231+ return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf()));
233+ if !directory.exists() {
234+ return Err(SessionError::MissingDirectory(directory.to_path_buf()));
236+ Ok(directory.canonicalize()?)
239+ fn instance_dir_for(&self, directory: &Path) -> PathBuf {
240+ directory.join(INSTANCE_DIR_NAME)
243+ fn remove_orphans(&self, index: &mut SessionIndex) -> usize {
244+ let before = index.sessions.len();
247+ .retain(|s| self.instance_dir_for(&s.directory).is_dir());
248+ before - index.sessions.len()
251+ fn read_index(&self) -> Result<SessionIndex, SessionError> {
252+ if !self.index_path.exists() {
253+ return Ok(SessionIndex::default());
255+ let content = fs::read_to_string(&self.index_path)?;
256+ Ok(toml::from_str(&content)?)
259+ fn write_index(&self, index: &SessionIndex) -> Result<(), SessionError> {
260+ fs::create_dir_all(&self.global_dir)?;
261+ let content = toml::to_string_pretty(index)?;
262+ atomic_write(&self.index_path, content.as_bytes())?;
267+fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
268+ let Some(parent) = path.parent() else {
269+ return Err(io::Error::new(
270+ io::ErrorKind::InvalidInput,
271+ "path has no parent directory",
275+ fs::create_dir_all(parent)?;
276+ let mut temp = tempfile::Builder::new()
277+ .prefix(SESSION_TEMP_PREFIX)
278+ .suffix(SESSION_TOML_SUFFIX)
279+ .tempfile_in(parent)?;
280+ temp.write_all(content)?;
282+ temp.persist(path).map_err(|err| err.error)?;
290+ use tempfile::TempDir;
291+ use time::OffsetDateTime;
293+ fn manager(temp: &TempDir) -> SessionManager {
294+ SessionManager::with_global_dir(temp.path().join("global"))
297+ fn create_project_dir(temp: &TempDir) -> PathBuf {
298+ let dir = temp.path().join("project");
299+ fs::create_dir_all(&dir).unwrap();
304+ fn create_session_writes_index_and_instance_dir() {
305+ let temp = TempDir::new().unwrap();
306+ let mgr = manager(&temp);
307+ let project_dir = create_project_dir(&temp);
309+ let session = mgr.create_session(&project_dir).unwrap();
311+ assert!(mgr.index_path().exists());
312+ assert_eq!(session.directory, project_dir.canonicalize().unwrap());
313+ assert_eq!(session.ref_count, 1);
314+ assert!(project_dir.join(INSTANCE_DIR_NAME).is_dir());
318+ fn create_session_rejects_non_absolute_directory() {
319+ let temp = TempDir::new().unwrap();
320+ let mgr = manager(&temp);
322+ let err = mgr.create_session(Path::new("relative/path")).unwrap_err();
324+ assert!(matches!(err, SessionError::NonAbsoluteDirectory(_)));
328+ fn create_session_rejects_missing_directory() {
329+ let temp = TempDir::new().unwrap();
330+ let mgr = manager(&temp);
331+ let missing = temp.path().join("missing");
333+ let err = mgr.create_session(&missing).unwrap_err();
335+ assert!(matches!(err, SessionError::MissingDirectory(_)));
339+ fn create_session_rejects_duplicate_directory() {
340+ let temp = TempDir::new().unwrap();
341+ let mgr = manager(&temp);
342+ let project_dir = create_project_dir(&temp);
344+ let _session = mgr.create_session(&project_dir).unwrap();
345+ let err = mgr.create_session(&project_dir).unwrap_err();
347+ assert!(matches!(err, SessionError::DirectoryAlreadyHasSession(_)));
351+ fn get_or_create_increments_ref_count_for_existing_session() {
352+ let temp = TempDir::new().unwrap();
353+ let mgr = manager(&temp);
354+ let project_dir = create_project_dir(&temp);
356+ let first = mgr.create_session(&project_dir).unwrap();
357+ let second = mgr.get_or_create_session(&project_dir).unwrap();
359+ assert_eq!(first.id, second.id);
360+ assert_eq!(second.ref_count, 2);
364+ fn decrement_ref_count_errors_on_underflow() {
365+ let temp = TempDir::new().unwrap();
366+ let mgr = manager(&temp);
367+ let project_dir = create_project_dir(&temp);
369+ let session = mgr.create_session(&project_dir).unwrap();
370+ let session = mgr.decrement_ref_count(session.id).unwrap();
371+ assert_eq!(session.ref_count, 0);
373+ let err = mgr.decrement_ref_count(session.id).unwrap_err();
374+ assert!(matches!(err, SessionError::RefCountUnderflow(_)));
378+ fn list_sessions_cleans_orphans() {
379+ let temp = TempDir::new().unwrap();
380+ let mgr = manager(&temp);
381+ let project_dir = create_project_dir(&temp);
383+ let _session = mgr.create_session(&project_dir).unwrap();
384+ fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
386+ let sessions = mgr.list_sessions().unwrap();
387+ assert!(sessions.is_empty());
391+ fn delete_session_removes_instance_dir_and_index() {
392+ let temp = TempDir::new().unwrap();
393+ let mgr = manager(&temp);
394+ let project_dir = create_project_dir(&temp);
396+ let session = mgr.create_session(&project_dir).unwrap();
397+ let removed = mgr.delete_session(session.id).unwrap();
400+ assert!(!project_dir.join(INSTANCE_DIR_NAME).exists());
401+ assert!(mgr.list_sessions().unwrap().is_empty());
405+ fn invalid_toml_returns_error() {
406+ let temp = TempDir::new().unwrap();
407+ let mgr = manager(&temp);
408+ let index_path = mgr.index_path();
409+ fs::create_dir_all(index_path.parent().unwrap()).unwrap();
410+ fs::write(index_path, "this is not toml").unwrap();
412+ let err = mgr.list_sessions().unwrap_err();
414+ assert!(matches!(err, SessionError::TomlDe(_)));
418+ fn bump_last_active_updates_timestamp() {
419+ let temp = TempDir::new().unwrap();
420+ let mgr = manager(&temp);
421+ let project_dir = create_project_dir(&temp);
423+ let session = mgr.create_session(&project_dir).unwrap();
424+ let before = session.last_active;
425+ std::thread::sleep(std::time::Duration::from_millis(5));
426+ let updated = mgr.bump_last_active(session.id).unwrap();
427+ let now = OffsetDateTime::now_utc();
429+ assert!(updated.last_active >= before);
430+ assert!(updated.last_active <= now);
434+ fn cleanup_orphans_returns_removed_count() {
435+ let temp = TempDir::new().unwrap();
436+ let mgr = manager(&temp);
437+ let project_dir = create_project_dir(&temp);
439+ let _session = mgr.create_session(&project_dir).unwrap();
440+ fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
442+ let removed = mgr.cleanup_orphans().unwrap();
443+ assert_eq!(removed, 1);
444+ assert!(mgr.list_sessions().unwrap().is_empty());
448+ fn increment_ref_count_updates_last_active() {
449+ let temp = TempDir::new().unwrap();
450+ let mgr = manager(&temp);
451+ let project_dir = create_project_dir(&temp);
453+ let session = mgr.create_session(&project_dir).unwrap();
454+ std::thread::sleep(std::time::Duration::from_millis(5));
455+ let updated = mgr.increment_ref_count(session.id).unwrap();
457+ assert_eq!(updated.ref_count, session.ref_count + 1);
458+ assert!(updated.last_active >= session.last_active);
462+ fn decrement_ref_count_updates_last_active() {
463+ let temp = TempDir::new().unwrap();
464+ let mgr = manager(&temp);
465+ let project_dir = create_project_dir(&temp);
467+ let session = mgr.create_session(&project_dir).unwrap();
468+ std::thread::sleep(std::time::Duration::from_millis(5));
469+ let updated = mgr.decrement_ref_count(session.id).unwrap();
471+ assert_eq!(updated.ref_count, session.ref_count - 1);
472+ assert!(updated.last_active >= session.last_active);
476+ fn list_sessions_returns_active_sessions() {
477+ let temp = TempDir::new().unwrap();
478+ let mgr = manager(&temp);
479+ let project_dir = create_project_dir(&temp);
481+ let session = mgr.create_session(&project_dir).unwrap();
482+ let sessions = mgr.list_sessions().unwrap();
484+ assert_eq!(sessions.len(), 1);
485+ assert_eq!(sessions[0].id, session.id);