Some checks failed
ci/someci/tag/woodpecker/5 Pipeline is pending
ci/someci/tag/woodpecker/6 Pipeline is pending
ci/someci/tag/woodpecker/7 Pipeline is pending
ci/someci/tag/woodpecker/1 Pipeline failed
ci/someci/tag/woodpecker/2 Pipeline failed
ci/someci/tag/woodpecker/3 Pipeline failed
ci/someci/tag/woodpecker/4 Pipeline failed
- Introduce multiple built-in themes (`default_dark`, `default_light`, `gruvbox`, `dracula`, `solarized`, `midnight-ocean`, `rose-pine`, `monokai`, `material-dark`, `material-light`). - Implement theming system with customizable color schemes for all UI components in the TUI. - Include documentation for themes in `themes/README.md`. - Add fallback mechanisms for default themes in case of parsing errors. - Support custom themes with overrides via configuration.
310 lines
10 KiB
Rust
310 lines
10 KiB
Rust
//! 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<String>,
|
|
/// Optional AI-generated description
|
|
pub description: Option<String>,
|
|
/// 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<Self> {
|
|
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<Self> {
|
|
// 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<PathBuf> {
|
|
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<String>,
|
|
) -> Result<PathBuf> {
|
|
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<String>,
|
|
description: Option<String>,
|
|
) -> Result<PathBuf> {
|
|
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<Path>) -> Result<Conversation> {
|
|
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<Vec<SessionMeta>> {
|
|
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<Path>) -> 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::<String>()
|
|
.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());
|
|
}
|
|
}
|