mirror of
https://github.com/robcholz/vibebox.git
synced 2026-05-21 06:56:48 +02:00
375 lines
12 KiB
Rust
375 lines
12 KiB
Rust
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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[serde(default)]
|
|
last_active: Option<String>,
|
|
}
|
|
|
|
#[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<Self, SessionError> {
|
|
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<Vec<PathBuf>, 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<Vec<SessionRecord>, 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<PathBuf, SessionError> {
|
|
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<SessionEntry>, 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<SessionEntry, SessionError> {
|
|
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<InstanceMetadata, SessionError> {
|
|
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")
|
|
);
|
|
}
|
|
}
|