use super::store::Session;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct SessionManager {
sessions_dir: PathBuf,
cache: HashMap<String, Session>,
}
impl SessionManager {
pub fn new<P: AsRef<Path>>(workspace: P) -> Self {
let sessions_dir = workspace.as_ref().join("sessions");
Self {
sessions_dir,
cache: HashMap::new(),
}
}
pub fn get_or_create(&mut self, key: impl Into<String>) -> &mut Session {
let key = key.into();
if !self.cache.contains_key(&key) {
let session = self.load(&key).unwrap_or_else(|| Session::new(&key));
self.cache.insert(key.clone(), session);
}
self.cache.get_mut(&key).unwrap()
}
pub fn get(&self, key: &str) -> Option<&Session> {
self.cache.get(key)
}
pub fn get_or_load(&mut self, key: &str) -> Option<&Session> {
if !self.cache.contains_key(key) {
if let Some(session) = self.load(key) {
self.cache.insert(key.to_string(), session);
} else {
return None;
}
}
self.cache.get(key)
}
fn load(&self, key: &str) -> Option<Session> {
let path = self.session_path(key);
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let mut messages = Vec::new();
let mut metadata = serde_json::Value::Object(serde_json::Map::new());
let mut created_at = None;
let mut last_consolidated: usize = 0;
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if value.get("_type").and_then(|v| v.as_str()) == Some("metadata") {
metadata = value.get("metadata").cloned().unwrap_or(metadata);
created_at = value
.get("created_at")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok());
last_consolidated = value
.get("last_consolidated")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
} else if let Ok(msg) = serde_json::from_value::<super::store::ChatMessage>(value) {
messages.push(msg);
}
}
}
Some(Session {
key: key.to_string(),
messages,
created_at: created_at.unwrap_or_else(chrono::Utc::now),
updated_at: chrono::Utc::now(),
metadata,
last_consolidated,
})
}
pub fn save(&self, session: &Session) -> crate::Result<()> {
std::fs::create_dir_all(&self.sessions_dir)?;
let path = self.session_path(&session.key);
let mut lines = Vec::new();
let metadata = serde_json::json!({
"_type": "metadata",
"created_at": session.created_at.to_rfc3339(),
"updated_at": session.updated_at.to_rfc3339(),
"metadata": session.metadata,
"last_consolidated": session.last_consolidated,
});
lines.push(serde_json::to_string(&metadata)?);
for msg in &session.messages {
lines.push(serde_json::to_string(msg)?);
}
std::fs::write(&path, lines.join("\n"))?;
Ok(())
}
pub fn delete(&mut self, key: &str) -> crate::Result<bool> {
self.cache.remove(key);
let path = self.session_path(key);
if path.exists() {
std::fs::remove_file(&path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn archive_and_reset(&mut self, key: &str) -> crate::Result<bool> {
self.cache.remove(key);
let path = self.session_path(key);
if path.exists() {
let safe_key = key.replace([':', '/', '\\'], "_");
let timestamp = chrono::Utc::now().timestamp_millis();
let archive_filename = format!("{}.reset.{}.jsonl", safe_key, timestamp);
let archive_path = self.sessions_dir.join(archive_filename);
std::fs::rename(&path, &archive_path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn list_sessions(&self) -> Vec<SessionInfo> {
let mut sessions = Vec::new();
if let Ok(entries) = std::fs::read_dir(&self.sessions_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".jsonl") {
let key = name.trim_end_matches(".jsonl").replace('_', ":");
if let Ok(content) = std::fs::read_to_string(entry.path()) {
if let Some(first_line) = content.lines().next() {
if let Ok(value) =
serde_json::from_str::<serde_json::Value>(first_line)
{
if value.get("_type").and_then(|v| v.as_str())
== Some("metadata")
{
sessions.push(SessionInfo {
key,
created_at: value
.get("created_at")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
updated_at: value
.get("updated_at")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
path: entry.path().to_string_lossy().to_string(),
});
}
}
}
}
}
}
}
}
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
sessions
}
fn session_path(&self, key: &str) -> PathBuf {
let safe_key = key.replace([':', '/', '\\'], "_");
self.sessions_dir.join(format!("{}.jsonl", safe_key))
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionInfo {
pub key: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub path: String,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_session_manager_creation() {
let temp_dir = TempDir::new().unwrap();
let manager = SessionManager::new(temp_dir.path());
assert!(manager.list_sessions().is_empty());
}
#[test]
fn test_get_or_create_session() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SessionManager::new(temp_dir.path());
let session = manager.get_or_create("telegram:123");
session.add_message("user", "Hello");
assert_eq!(session.messages.len(), 1);
assert_eq!(session.key, "telegram:123");
}
#[test]
fn test_save_and_load_session() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SessionManager::new(temp_dir.path());
let session = manager.get_or_create("test:456");
session.add_message("user", "Test message");
let key = session.key.clone();
manager.save(&manager.cache.get(&key).unwrap()).unwrap();
manager.cache.clear();
let session = manager.get_or_create("test:456");
assert_eq!(session.messages.len(), 1);
assert_eq!(session.messages[0].content, "Test message");
}
#[test]
fn test_archive_and_reset_session() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SessionManager::new(temp_dir.path());
let session = manager.get_or_create("archive:789");
session.add_message("user", "Message to be archived");
let key = session.key.clone();
manager.save(&manager.cache.get(&key).unwrap()).unwrap();
let archived = manager.archive_and_reset(&key).unwrap();
assert!(archived);
assert!(manager.cache.get(&key).is_none());
let new_session = manager.get_or_create("archive:789");
assert_eq!(new_session.messages.len(), 0);
let mut reset_files_count = 0;
for entry in std::fs::read_dir(temp_dir.path().join("sessions")).unwrap() {
let entry = entry.unwrap();
let file_name = entry.file_name().into_string().unwrap();
if file_name.contains(".reset.") {
reset_files_count += 1;
} else if file_name == "archive_789.jsonl" {
panic!("Original file still exists!");
}
}
assert_eq!(
reset_files_count, 1,
"Should have exactly one archived file"
);
}
#[test]
fn test_get_or_load_cache_hit() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SessionManager::new(temp_dir.path());
let session = manager.get_or_create("gui:chat-1");
session.add_message("user", "Hello");
let key = session.key.clone();
manager.save(&manager.cache.get(&key).unwrap()).unwrap();
let loaded = manager.get_or_load("gui:chat-1");
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().key, "gui:chat-1");
assert_eq!(loaded.unwrap().messages.len(), 1);
}
#[test]
fn test_get_or_load_disk_exists_cache_miss() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SessionManager::new(temp_dir.path());
let session = manager.get_or_create("gui:chat-2");
session.add_message("user", "From disk");
let key = session.key.clone();
manager.save(&manager.cache.get(&key).unwrap()).unwrap();
manager.cache.clear();
let loaded = manager.get_or_load("gui:chat-2");
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().key, "gui:chat-2");
assert_eq!(loaded.unwrap().messages[0].content, "From disk");
}
#[test]
fn test_get_or_load_disk_not_exists() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SessionManager::new(temp_dir.path());
let loaded = manager.get_or_load("gui:nonexistent");
assert!(loaded.is_none());
}
}