@@ -6,28 +6,27 @@ use std::{
7use serde::{Deserialize, Serialize};
7use serde::{Deserialize, Serialize};
9+use crate::config::CONFIG_FILENAME;
9pub const INSTANCE_DIR_NAME: &str = ".vibebox";
11pub const INSTANCE_DIR_NAME: &str = ".vibebox";
10pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
12pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
11pub const GLOBAL_DIR_NAME: &str = ".vibebox";
13pub const GLOBAL_DIR_NAME: &str = ".vibebox";
12−pub const GLOBAL_SESSION_FILENAME: &str = "session.toml";
14+pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
13pub const SESSION_TEMP_PREFIX: &str = "sessions";
15pub const SESSION_TEMP_PREFIX: &str = "sessions";
14pub const SESSION_TOML_SUFFIX: &str = ".toml";
16pub const SESSION_TOML_SUFFIX: &str = ".toml";
15−use crate::config::CONFIG_FILENAME;
17+const SESSIONS_DIR_NAME: &str = "sessions";
16−pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
18−#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19+#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SessionRecord {
20pub struct SessionRecord {
20 pub directory: PathBuf,
21 pub directory: PathBuf,
22− pub id: Option<String>,
22+ pub id: String,
24 pub last_active: Option<String>,
23 pub last_active: Option<String>,
27−#[derive(Debug, Default, Serialize, Deserialize)]
26+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28−struct GlobalSessionIndex {
27+struct SessionEntry {
29− #[serde(default)]
28+ pub directory: PathBuf,
30− directories: Vec<PathBuf>,
29+ pub id: String,
33#[derive(Debug, Default, Deserialize)]
32#[derive(Debug, Default, Deserialize)]
@@ -40,8 +39,7 @@ struct InstanceMetadata {
41#[derive(Debug)]
40#[derive(Debug)]
42pub struct SessionManager {
41pub struct SessionManager {
43− global_dir: PathBuf,
42+ sessions_dir: PathBuf,
44− session_path: PathBuf,
47#[derive(Debug, thiserror::Error)]
45#[derive(Debug, thiserror::Error)]
@@ -69,55 +67,84 @@ impl SessionManager {
71 pub fn with_global_dir(global_dir: PathBuf) -> Self {
69 pub fn with_global_dir(global_dir: PathBuf) -> Self {
72− let session_path = global_dir.join(GLOBAL_SESSION_FILENAME);
70+ let sessions_dir = global_dir.join(SESSIONS_DIR_NAME);
73− Self {
71+ Self { sessions_dir }
79 pub fn index_path(&self) -> &Path {
74 pub fn index_path(&self) -> &Path {
80− &self.session_path
75+ &self.sessions_dir
83 pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
78 pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
84 let directory = self.normalize_directory(directory)?;
79 let directory = self.normalize_directory(directory)?;
85− let mut index = self.read_global_index()?;
80+ fs::create_dir_all(&self.sessions_dir)?;
86− let mut changed = false;
88− let removed = prune_invalid_dirs(&mut index);
82+ let (mut sessions, removed) = self.prune_stale_sessions()?;
89− if removed > 0 {
83+ let has_config = is_vibebox_dir(&directory);
90− changed = true;
84+ let mut added = false;
93− if is_vibebox_dir(&directory) && !index.directories.iter().any(|dir| dir == &directory) {
86+ if has_config {
94− index.directories.push(directory);
87+ let meta = read_instance_metadata(&directory)?;
88+ if let Some(id) = meta.id {
89+ let record = SessionEntry {
90+ directory: directory.clone(),
93+ self.write_session_record(&record)?;
94+ if let Some(existing) = sessions.iter_mut().find(|s| s.id == id) {
97+ sessions.push(record);
95− changed = true;
99+ added = true;
102+ directory = %directory.display(),
103+ "missing session id in instance.toml"
98− if changed {
108+ if removed > 0 || added {
99− self.write_global_index(&index)?;
109+ tracing::info!(
110+ path = %self.sessions_dir.display(),
113+ entries = sessions.len(),
114+ "updated global sessions"
118+ path = %self.sessions_dir.display(),
119+ entries = sessions.len(),
121+ "global sessions unchanged"
102− Ok(index.directories)
125+ Ok(sessions.into_iter().map(|s| s.directory).collect())
105 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
128 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
106− let mut index = self.read_global_index()?;
129+ let (sessions, removed) = self.prune_stale_sessions()?;
107− let removed = prune_invalid_dirs(&mut index);
108 if removed > 0 {
130 if removed > 0 {
109− self.write_global_index(&index)?;
131+ tracing::info!(
132+ path = %self.sessions_dir.display(),
134+ entries = sessions.len(),
135+ "pruned stale sessions"
111− let mut sessions = Vec::with_capacity(index.directories.len());
138+ let mut records = Vec::with_capacity(sessions.len());
112− for directory in index.directories {
139+ for session in sessions {
113− let meta = read_instance_metadata(&directory)?;
140+ let meta = read_instance_metadata(&session.directory)?;
114− sessions.push(SessionRecord {
141+ records.push(SessionRecord {
115− directory,
142+ directory: session.directory,
116− id: meta.id,
143+ id: session.id,
117 last_active: meta.last_active,
144 last_active: meta.last_active,
120− Ok(sessions)
147+ Ok(records)
123 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
150 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
@@ -130,20 +157,55 @@ impl SessionManager {
130 Ok(directory.canonicalize()?)
157 Ok(directory.canonicalize()?)
133− fn read_global_index(&self) -> Result<GlobalSessionIndex, SessionError> {
160+ fn session_path_for(&self, id: &str) -> PathBuf {
134− if !self.session_path.exists() {
161+ let filename = format!("{id}.toml");
135− return Ok(GlobalSessionIndex::default());
162+ self.sessions_dir.join(filename)
137− let content = fs::read_to_string(&self.session_path)?;
138− Ok(toml::from_str(&content)?)
141− fn write_global_index(&self, index: &GlobalSessionIndex) -> Result<(), SessionError> {
165+ fn write_session_record(&self, record: &SessionEntry) -> Result<(), SessionError> {
142− fs::create_dir_all(&self.global_dir)?;
166+ fs::create_dir_all(&self.sessions_dir)?;
167+ let path = self.session_path_for(&record.id);
143− let content = toml::to_string_pretty(index)?;
168+ let content = toml::to_string_pretty(record)?;
144− atomic_write(&self.session_path, content.as_bytes())?;
169+ atomic_write(&path, content.as_bytes())?;
171+ path = %path.display(),
172+ "wrote session record"
177+ fn prune_stale_sessions(&self) -> Result<(Vec<SessionEntry>, usize), SessionError> {
178+ if !self.sessions_dir.exists() {
179+ return Ok((Vec::new(), 0));
182+ let mut sessions = Vec::new();
183+ let mut removed = 0usize;
185+ for entry in fs::read_dir(&self.sessions_dir)? {
187+ let path = entry.path();
188+ if !path.is_file() {
191+ let record = read_session_file(&path)?;
192+ if !is_vibebox_dir(&record.directory) {
193+ let _ = fs::remove_file(&path);
197+ if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
198+ if stem != record.id {
199+ let _ = fs::remove_file(&path);
204+ sessions.push(record);
207+ Ok((sessions, removed))
149fn is_vibebox_dir(directory: &Path) -> bool {
211fn is_vibebox_dir(directory: &Path) -> bool {
@@ -153,10 +215,13 @@ fn is_vibebox_dir(directory: &Path) -> bool {
153 directory.join(CONFIG_FILENAME).is_file()
215 directory.join(CONFIG_FILENAME).is_file()
156−fn prune_invalid_dirs(index: &mut GlobalSessionIndex) -> usize {
218+fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
157− let before = index.directories.len();
219+ let raw = fs::read_to_string(path)?;
158− index.directories.retain(|dir| is_vibebox_dir(dir));
220+ let record: SessionEntry = toml::from_str(&raw)?;
159− before - index.directories.len()
221+ if record.id.trim().is_empty() {
222+ return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
162fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
227fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
@@ -216,70 +281,87 @@ mod tests {
284+ fn write_instance(project_dir: &Path, id: &str, last_active: &str) {
285+ let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
286+ fs::create_dir_all(&instance_dir).unwrap();
287+ let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
288+ fs::write(instance_dir.join(INSTANCE_TOML_FILENAME), content).unwrap();
220 fn update_global_sessions_adds_directory() {
292 fn update_global_sessions_adds_directory() {
221 let temp = TempDir::new().unwrap();
293 let temp = TempDir::new().unwrap();
222 let mgr = manager(&temp);
294 let mgr = manager(&temp);
223 let project_dir = create_project_dir(&temp);
295 let project_dir = create_project_dir(&temp);
224− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
296+ fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
299+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
300+ "2026-02-07T05:00:00Z",
226 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
303 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
228 assert_eq!(dirs.len(), 1);
305 assert_eq!(dirs.len(), 1);
229 assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
306 assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
230 assert!(mgr.index_path().exists());
307 assert!(mgr.index_path().exists());
309+ let session_path = mgr
311+ .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml");
312+ assert!(session_path.exists());
234− fn invalid_toml_returns_error() {
316+ fn update_global_sessions_removes_missing_vibebox_toml() {
235 let temp = TempDir::new().unwrap();
317 let temp = TempDir::new().unwrap();
236 let mgr = manager(&temp);
318 let mgr = manager(&temp);
237− let index_path = mgr.index_path();
319+ let project_dir = create_project_dir(&temp);
238− fs::create_dir_all(index_path.parent().unwrap()).unwrap();
320+ fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
239− fs::write(index_path, "this is not toml").unwrap();
321+ write_instance(
323+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
324+ "2026-02-07T05:00:00Z",
326+ let _ = mgr.update_global_sessions(&project_dir).unwrap();
328+ fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
241− let err = mgr.list_sessions().unwrap_err();
329+ let sessions = mgr.list_sessions().unwrap();
330+ assert!(sessions.is_empty());
243− assert!(matches!(err, SessionError::TomlDe(_)));
332+ let session_path = mgr
334+ .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml");
335+ assert!(!session_path.exists());
247− fn update_global_sessions_removes_missing_vibebox_toml() {
339+ fn invalid_toml_returns_error() {
248 let temp = TempDir::new().unwrap();
340 let temp = TempDir::new().unwrap();
249 let mgr = manager(&temp);
341 let mgr = manager(&temp);
250− let project_dir = create_project_dir(&temp);
342+ fs::create_dir_all(mgr.index_path()).unwrap();
251− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
343+ fs::write(mgr.index_path().join("bad.toml"), "not toml").unwrap();
253− let _ = mgr.update_global_sessions(&project_dir).unwrap();
345+ let err = mgr.list_sessions().unwrap_err();
254− fs::remove_file(project_dir.join(VIBEBOX_CONFIG_FILENAME)).unwrap();
346+ assert!(matches!(err, SessionError::TomlDe(_)));
256− let dirs = mgr.update_global_sessions(&project_dir).unwrap();
257− assert!(dirs.is_empty());
261− fn list_sessions_reads_instance_metadata() {
350+ fn list_sessions_reads_session_files() {
262 let temp = TempDir::new().unwrap();
351 let temp = TempDir::new().unwrap();
263 let mgr = manager(&temp);
352 let mgr = manager(&temp);
264 let project_dir = create_project_dir(&temp);
353 let project_dir = create_project_dir(&temp);
265− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
354+ fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
267− let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
356+ &project_dir,
268− fs::create_dir_all(&instance_dir).unwrap();
270− instance_dir.join(INSTANCE_TOML_FILENAME),
271− "id = \"019bf290-cccc-7c23-ba1d-dce7e6d40693\"\nlast_active = \"2026-02-07T05:00:00Z\"\n",
357+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
358+ "2026-02-07T05:00:00Z",
275 let _ = mgr.update_global_sessions(&project_dir).unwrap();
360 let _ = mgr.update_global_sessions(&project_dir).unwrap();
276− let sessions = mgr.list_sessions().unwrap();
362+ let sessions = mgr.list_sessions().unwrap();
278 assert_eq!(sessions.len(), 1);
363 assert_eq!(sessions.len(), 1);
280− sessions[0].id.as_deref(),
281− Some("019bf290-cccc-7c23-ba1d-dce7e6d40693")
364+ assert_eq!(sessions[0].id, "019bf290-cccc-7c23-ba1d-dce7e6d40693");
283 assert_eq!(
365 assert_eq!(
284 sessions[0].last_active.as_deref(),
366 sessions[0].last_active.as_deref(),
285 Some("2026-02-07T05:00:00Z")
367 Some("2026-02-07T05:00:00Z")