//! Session persistence and storage management use crate::types::Conversation; use crate::{Error, Result}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use std::time::SystemTime; /// Metadata about a saved session #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionMeta { /// Session file path pub path: PathBuf, /// Conversation ID pub id: uuid::Uuid, /// Optional session name pub name: Option, /// Optional AI-generated description pub description: Option, /// Number of messages in the conversation pub message_count: usize, /// Model used pub model: String, /// When the session was created pub created_at: SystemTime, /// When the session was last updated pub updated_at: SystemTime, } /// Storage manager for persisting conversations pub struct StorageManager { sessions_dir: PathBuf, } impl StorageManager { /// Create a new storage manager with the default sessions directory pub fn new() -> Result { let sessions_dir = Self::default_sessions_dir()?; Self::with_directory(sessions_dir) } /// Create a storage manager with a custom sessions directory pub fn with_directory(sessions_dir: PathBuf) -> Result { // Ensure the directory exists if !sessions_dir.exists() { fs::create_dir_all(&sessions_dir).map_err(|e| { Error::Storage(format!("Failed to create sessions directory: {}", e)) })?; } Ok(Self { sessions_dir }) } /// Get the default sessions directory /// - Linux: ~/.local/share/owlen/sessions /// - Windows: %APPDATA%\owlen\sessions /// - macOS: ~/Library/Application Support/owlen/sessions pub fn default_sessions_dir() -> Result { let data_dir = dirs::data_local_dir() .ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?; Ok(data_dir.join("owlen").join("sessions")) } /// Save a conversation to disk pub fn save_conversation( &self, conversation: &Conversation, name: Option, ) -> Result { self.save_conversation_with_description(conversation, name, None) } /// Save a conversation to disk with an optional description pub fn save_conversation_with_description( &self, conversation: &Conversation, name: Option, description: Option, ) -> Result { let filename = if let Some(ref session_name) = name { // Use provided name, sanitized let sanitized = sanitize_filename(session_name); format!("{}_{}.json", conversation.id, sanitized) } else { // Use conversation ID and timestamp let timestamp = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); format!("{}_{}.json", conversation.id, timestamp) }; let path = self.sessions_dir.join(filename); // Create a saveable version with the name and description let mut save_conv = conversation.clone(); if name.is_some() { save_conv.name = name; } if description.is_some() { save_conv.description = description; } let json = serde_json::to_string_pretty(&save_conv) .map_err(|e| Error::Storage(format!("Failed to serialize conversation: {}", e)))?; fs::write(&path, json) .map_err(|e| Error::Storage(format!("Failed to write session file: {}", e)))?; Ok(path) } /// Load a conversation from disk pub fn load_conversation(&self, path: impl AsRef) -> Result { let content = fs::read_to_string(path.as_ref()) .map_err(|e| Error::Storage(format!("Failed to read session file: {}", e)))?; let conversation: Conversation = serde_json::from_str(&content) .map_err(|e| Error::Storage(format!("Failed to parse session file: {}", e)))?; Ok(conversation) } /// List all saved sessions with metadata pub fn list_sessions(&self) -> Result> { let mut sessions = Vec::new(); let entries = fs::read_dir(&self.sessions_dir) .map_err(|e| Error::Storage(format!("Failed to read sessions directory: {}", e)))?; for entry in entries { let entry = entry .map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; } // Try to load the conversation to extract metadata match self.load_conversation(&path) { Ok(conv) => { sessions.push(SessionMeta { path: path.clone(), id: conv.id, name: conv.name.clone(), description: conv.description.clone(), message_count: conv.messages.len(), model: conv.model.clone(), created_at: conv.created_at, updated_at: conv.updated_at, }); } Err(_) => { // Skip files that can't be parsed continue; } } } // Sort by updated_at, most recent first sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); Ok(sessions) } /// Delete a saved session pub fn delete_session(&self, path: impl AsRef) -> Result<()> { fs::remove_file(path.as_ref()) .map_err(|e| Error::Storage(format!("Failed to delete session file: {}", e))) } /// Get the sessions directory path pub fn sessions_dir(&self) -> &Path { &self.sessions_dir } } impl Default for StorageManager { fn default() -> Self { Self::new().expect("Failed to create default storage manager") } } /// Sanitize a filename by removing invalid characters fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| { if c.is_alphanumeric() || c == '_' || c == '-' { c } else if c.is_whitespace() { '_' } else { '-' } }) .collect::() .chars() .take(50) // Limit length .collect() } #[cfg(test)] mod tests { use super::*; use crate::types::Message; use tempfile::TempDir; #[test] fn test_platform_specific_default_path() { let path = StorageManager::default_sessions_dir().unwrap(); // Verify it contains owlen/sessions assert!(path.to_string_lossy().contains("owlen")); assert!(path.to_string_lossy().contains("sessions")); // Platform-specific checks #[cfg(target_os = "linux")] { // Linux should use ~/.local/share/owlen/sessions assert!(path.to_string_lossy().contains(".local/share")); } #[cfg(target_os = "windows")] { // Windows should use AppData assert!(path.to_string_lossy().contains("AppData")); } #[cfg(target_os = "macos")] { // macOS should use ~/Library/Application Support assert!(path .to_string_lossy() .contains("Library/Application Support")); } println!("Default sessions directory: {}", path.display()); } #[test] fn test_sanitize_filename() { assert_eq!(sanitize_filename("Hello World"), "Hello_World"); assert_eq!(sanitize_filename("test/path\\file"), "test-path-file"); assert_eq!(sanitize_filename("file:name?"), "file-name-"); } #[test] fn test_save_and_load_conversation() { let temp_dir = TempDir::new().unwrap(); let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap(); let mut conv = Conversation::new("test-model".to_string()); conv.messages.push(Message::user("Hello".to_string())); conv.messages .push(Message::assistant("Hi there!".to_string())); // Save conversation let path = storage .save_conversation(&conv, Some("test_session".to_string())) .unwrap(); assert!(path.exists()); // Load conversation let loaded = storage.load_conversation(&path).unwrap(); assert_eq!(loaded.id, conv.id); assert_eq!(loaded.model, conv.model); assert_eq!(loaded.messages.len(), 2); assert_eq!(loaded.name, Some("test_session".to_string())); } #[test] fn test_list_sessions() { let temp_dir = TempDir::new().unwrap(); let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap(); // Create multiple sessions for i in 0..3 { let mut conv = Conversation::new("test-model".to_string()); conv.messages.push(Message::user(format!("Message {}", i))); storage .save_conversation(&conv, Some(format!("session_{}", i))) .unwrap(); } // List sessions let sessions = storage.list_sessions().unwrap(); assert_eq!(sessions.len(), 3); // Check that sessions are sorted by updated_at (most recent first) for i in 0..sessions.len() - 1 { assert!(sessions[i].updated_at >= sessions[i + 1].updated_at); } } #[test] fn test_delete_session() { let temp_dir = TempDir::new().unwrap(); let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap(); let conv = Conversation::new("test-model".to_string()); let path = storage.save_conversation(&conv, None).unwrap(); assert!(path.exists()); storage.delete_session(&path).unwrap(); assert!(!path.exists()); } }