@@ -0,0 +1,487 @@
1+use std::{
2+ env, fs,
3+ io::{self, Write},
4+ path::{Path, PathBuf},
5+};
6+
7+use serde::{Deserialize, Serialize};
8+use time::OffsetDateTime;
9+use uuid::Uuid;
10+
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";
16+
17+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18+pub struct SessionRecord {
19+ pub id: Uuid,
20+ pub directory: PathBuf,
21+ #[serde(with = "time::serde::rfc3339")]
22+ pub last_active: OffsetDateTime,
23+ #[serde(default)]
24+ pub ref_count: u64,
25+}
26+
27+#[derive(Debug, Default, Serialize, Deserialize)]
28+struct SessionIndex {
29+ #[serde(default)]
30+ sessions: Vec<SessionRecord>,
31+}
32+
33+#[derive(Debug)]
34+pub struct SessionManager {
35+ global_dir: PathBuf,
36+ index_path: PathBuf,
37+}
38+
39+#[derive(Debug, thiserror::Error)]
40+pub enum SessionError {
41+ #[error("HOME environment variable is not set")]
42+ MissingHome,
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),
59+}
60+
61+impl SessionManager {
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),
66+ ))
67+ }
68+
69+ pub fn with_global_dir(global_dir: PathBuf) -> Self {
70+ let index_path = global_dir.join(SESSION_INDEX_FILENAME);
71+ Self {
72+ global_dir,
73+ index_path,
74+ }
75+ }
76+
77+ pub fn index_path(&self) -> &Path {
78+ &self.index_path
79+ }
80+
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()?;
84+
85+ if index.sessions.iter().any(|s| s.directory == directory) {
86+ return Err(SessionError::DirectoryAlreadyHasSession(directory));
87+ }
88+
89+ let now = OffsetDateTime::now_utc();
90+ let session = SessionRecord {
91+ id: Uuid::now_v7(),
92+ directory: directory.clone(),
93+ last_active: now,
94+ ref_count: 1,
95+ };
96+
97+ fs::create_dir_all(self.instance_dir_for(&directory))?;
98+ index.sessions.push(session.clone());
99+ self.write_index(&index)?;
100+
101+ Ok(session)
102+ }
103+
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()?;
107+
108+ if let Some(pos) = index.sessions.iter().position(|s| s.directory == directory) {
109+ if !self
110+ .instance_dir_for(&index.sessions[pos].directory)
111+ .is_dir()
112+ {
113+ index.sessions.remove(pos);
114+ } else {
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)?;
121+ return Ok(updated);
122+ }
123+ }
124+
125+ let now = OffsetDateTime::now_utc();
126+ let session = SessionRecord {
127+ id: Uuid::now_v7(),
128+ directory: directory.clone(),
129+ last_active: now,
130+ ref_count: 1,
131+ };
132+
133+ fs::create_dir_all(self.instance_dir_for(&directory))?;
134+ index.sessions.push(session.clone());
135+ self.write_index(&index)?;
136+
137+ Ok(session)
138+ }
139+
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);
143+ if removed > 0 {
144+ self.write_index(&index)?;
145+ }
146+ Ok(index.sessions)
147+ }
148+
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 {
152+ return Ok(false);
153+ };
154+
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) {
158+ Ok(_) => {}
159+ Err(err) if err.kind() == io::ErrorKind::NotFound => {}
160+ Err(err) => return Err(err.into()),
161+ }
162+
163+ self.write_index(&index)?;
164+ Ok(true)
165+ }
166+
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;
171+ Ok(())
172+ })
173+ }
174+
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;
180+ Ok(())
181+ })
182+ }
183+
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));
189+ }
190+ session.ref_count -= 1;
191+ session.last_active = now;
192+ Ok(())
193+ })
194+ }
195+
196+ pub fn cleanup_orphans(&self) -> Result<usize, SessionError> {
197+ let mut index = self.read_index()?;
198+ let removed = self.remove_orphans(&mut index);
199+ if removed > 0 {
200+ self.write_index(&index)?;
201+ }
202+ Ok(removed)
203+ }
204+
205+ fn update_session<F>(&self, id: Uuid, mut update: F) -> Result<SessionRecord, SessionError>
206+ where
207+ F: FnMut(&mut SessionRecord) -> Result<(), SessionError>,
208+ {
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));
212+ };
213+
214+ if !self
215+ .instance_dir_for(&index.sessions[pos].directory)
216+ .is_dir()
217+ {
218+ index.sessions.remove(pos);
219+ self.write_index(&index)?;
220+ return Err(SessionError::SessionNotFound(id));
221+ }
222+
223+ update(&mut index.sessions[pos])?;
224+ let updated = index.sessions[pos].clone();
225+ self.write_index(&index)?;
226+ Ok(updated)
227+ }
228+
229+ fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
230+ if !directory.is_absolute() {
231+ return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf()));
232+ }
233+ if !directory.exists() {
234+ return Err(SessionError::MissingDirectory(directory.to_path_buf()));
235+ }
236+ Ok(directory.canonicalize()?)
237+ }
238+
239+ fn instance_dir_for(&self, directory: &Path) -> PathBuf {
240+ directory.join(INSTANCE_DIR_NAME)
241+ }
242+
243+ fn remove_orphans(&self, index: &mut SessionIndex) -> usize {
244+ let before = index.sessions.len();
245+ index
246+ .sessions
247+ .retain(|s| self.instance_dir_for(&s.directory).is_dir());
248+ before - index.sessions.len()
249+ }
250+
251+ fn read_index(&self) -> Result<SessionIndex, SessionError> {
252+ if !self.index_path.exists() {
253+ return Ok(SessionIndex::default());
254+ }
255+ let content = fs::read_to_string(&self.index_path)?;
256+ Ok(toml::from_str(&content)?)
257+ }
258+
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())?;
263+ Ok(())
264+ }
265+}
266+
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",
272+ ));
273+ };
274+
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)?;
281+ temp.flush()?;
282+ temp.persist(path).map_err(|err| err.error)?;
283+ Ok(())
284+}
285+
286+#[cfg(test)]
287+mod tests {
288+ use super::*;
289+ use std::fs;
290+ use tempfile::TempDir;
291+ use time::OffsetDateTime;
292+
293+ fn manager(temp: &TempDir) -> SessionManager {
294+ SessionManager::with_global_dir(temp.path().join("global"))
295+ }
296+
297+ fn create_project_dir(temp: &TempDir) -> PathBuf {
298+ let dir = temp.path().join("project");
299+ fs::create_dir_all(&dir).unwrap();
300+ dir
301+ }
302+
303+ #[test]
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);
308+
309+ let session = mgr.create_session(&project_dir).unwrap();
310+
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());
315+ }
316+
317+ #[test]
318+ fn create_session_rejects_non_absolute_directory() {
319+ let temp = TempDir::new().unwrap();
320+ let mgr = manager(&temp);
321+
322+ let err = mgr.create_session(Path::new("relative/path")).unwrap_err();
323+
324+ assert!(matches!(err, SessionError::NonAbsoluteDirectory(_)));
325+ }
326+
327+ #[test]
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");
332+
333+ let err = mgr.create_session(&missing).unwrap_err();
334+
335+ assert!(matches!(err, SessionError::MissingDirectory(_)));
336+ }
337+
338+ #[test]
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);
343+
344+ let _session = mgr.create_session(&project_dir).unwrap();
345+ let err = mgr.create_session(&project_dir).unwrap_err();
346+
347+ assert!(matches!(err, SessionError::DirectoryAlreadyHasSession(_)));
348+ }
349+
350+ #[test]
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);
355+
356+ let first = mgr.create_session(&project_dir).unwrap();
357+ let second = mgr.get_or_create_session(&project_dir).unwrap();
358+
359+ assert_eq!(first.id, second.id);
360+ assert_eq!(second.ref_count, 2);
361+ }
362+
363+ #[test]
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);
368+
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);
372+
373+ let err = mgr.decrement_ref_count(session.id).unwrap_err();
374+ assert!(matches!(err, SessionError::RefCountUnderflow(_)));
375+ }
376+
377+ #[test]
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);
382+
383+ let _session = mgr.create_session(&project_dir).unwrap();
384+ fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
385+
386+ let sessions = mgr.list_sessions().unwrap();
387+ assert!(sessions.is_empty());
388+ }
389+
390+ #[test]
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);
395+
396+ let session = mgr.create_session(&project_dir).unwrap();
397+ let removed = mgr.delete_session(session.id).unwrap();
398+
399+ assert!(removed);
400+ assert!(!project_dir.join(INSTANCE_DIR_NAME).exists());
401+ assert!(mgr.list_sessions().unwrap().is_empty());
402+ }
403+
404+ #[test]
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();
411+
412+ let err = mgr.list_sessions().unwrap_err();
413+
414+ assert!(matches!(err, SessionError::TomlDe(_)));
415+ }
416+
417+ #[test]
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);
422+
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();
428+
429+ assert!(updated.last_active >= before);
430+ assert!(updated.last_active <= now);
431+ }
432+
433+ #[test]
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);
438+
439+ let _session = mgr.create_session(&project_dir).unwrap();
440+ fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
441+
442+ let removed = mgr.cleanup_orphans().unwrap();
443+ assert_eq!(removed, 1);
444+ assert!(mgr.list_sessions().unwrap().is_empty());
445+ }
446+
447+ #[test]
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);
452+
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();
456+
457+ assert_eq!(updated.ref_count, session.ref_count + 1);
458+ assert!(updated.last_active >= session.last_active);
459+ }
460+
461+ #[test]
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);
466+
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();
470+
471+ assert_eq!(updated.ref_count, session.ref_count - 1);
472+ assert!(updated.last_active >= session.last_active);
473+ }
474+
475+ #[test]
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);
480+
481+ let session = mgr.create_session(&project_dir).unwrap();
482+ let sessions = mgr.list_sessions().unwrap();
483+
484+ assert_eq!(sessions.len(), 1);
485+ assert_eq!(sessions[0].id, session.id);
486+ }
487+}