use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tauri::AppHandle; use tauri_plugin_store::StoreExt; const SESSIONS_STORE_KEY: &str = "sessions"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SavedSession { pub id: String, pub name: String, pub created_at: DateTime, pub last_activity_at: DateTime, pub working_directory: String, pub message_count: usize, pub preview: String, // First ~100 chars of conversation for preview pub messages: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SavedMessage { pub id: String, #[serde(rename = "type")] pub message_type: String, pub content: String, pub timestamp: DateTime, pub tool_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionListItem { pub id: String, pub name: String, pub created_at: DateTime, pub last_activity_at: DateTime, pub working_directory: String, pub message_count: usize, pub preview: String, } impl From<&SavedSession> for SessionListItem { fn from(session: &SavedSession) -> Self { SessionListItem { id: session.id.clone(), name: session.name.clone(), created_at: session.created_at, last_activity_at: session.last_activity_at, working_directory: session.working_directory.clone(), message_count: session.message_count, preview: session.preview.clone(), } } } fn load_all_sessions(app: &AppHandle) -> Result, String> { let store = app .store("hikari-sessions.json") .map_err(|e| e.to_string())?; match store.get(SESSIONS_STORE_KEY) { Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()), None => Ok(Vec::new()), } } fn save_all_sessions(app: &AppHandle, sessions: &[SavedSession]) -> Result<(), String> { let store = app .store("hikari-sessions.json") .map_err(|e| e.to_string())?; let value = serde_json::to_value(sessions).map_err(|e| e.to_string())?; store.set(SESSIONS_STORE_KEY, value); store.save().map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn list_sessions(app: AppHandle) -> Result, String> { let sessions = load_all_sessions(&app)?; let mut items: Vec = sessions.iter().map(SessionListItem::from).collect(); // Sort by last activity, most recent first items.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); Ok(items) } #[tauri::command] pub async fn save_session(app: AppHandle, session: SavedSession) -> Result<(), String> { let mut sessions = load_all_sessions(&app)?; // Update existing or add new if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) { *existing = session; } else { sessions.push(session); } save_all_sessions(&app, &sessions) } #[tauri::command] pub async fn load_session(app: AppHandle, session_id: String) -> Result, String> { let sessions = load_all_sessions(&app)?; Ok(sessions.into_iter().find(|s| s.id == session_id)) } #[tauri::command] pub async fn delete_session(app: AppHandle, session_id: String) -> Result<(), String> { let mut sessions = load_all_sessions(&app)?; sessions.retain(|s| s.id != session_id); save_all_sessions(&app, &sessions) } #[tauri::command] pub async fn search_sessions(app: AppHandle, query: String) -> Result, String> { let sessions = load_all_sessions(&app)?; let query_lower = query.to_lowercase(); let mut matching: Vec = sessions .iter() .filter(|s| { s.name.to_lowercase().contains(&query_lower) || s.preview.to_lowercase().contains(&query_lower) || s.working_directory.to_lowercase().contains(&query_lower) || s.messages .iter() .any(|m| m.content.to_lowercase().contains(&query_lower)) }) .map(SessionListItem::from) .collect(); // Sort by last activity, most recent first matching.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); Ok(matching) } #[tauri::command] pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> { save_all_sessions(&app, &[]) } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; fn create_test_session(id: &str, name: &str) -> SavedSession { SavedSession { id: id.to_string(), name: name.to_string(), created_at: Utc::now(), last_activity_at: Utc::now(), working_directory: "/home/test".to_string(), message_count: 5, preview: "Hello world".to_string(), messages: vec![], } } fn create_test_message(id: &str, content: &str, msg_type: &str) -> SavedMessage { SavedMessage { id: id.to_string(), message_type: msg_type.to_string(), content: content.to_string(), timestamp: Utc::now(), tool_name: None, } } #[test] fn test_session_list_item_from_saved_session() { let session = SavedSession { id: "test-id".to_string(), name: "Test Session".to_string(), created_at: Utc::now(), last_activity_at: Utc::now(), working_directory: "/home/test".to_string(), message_count: 5, preview: "Hello world".to_string(), messages: vec![], }; let item = SessionListItem::from(&session); assert_eq!(item.id, "test-id"); assert_eq!(item.name, "Test Session"); assert_eq!(item.message_count, 5); } #[test] fn test_session_list_item_preserves_all_fields() { let created = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); let last_activity = Utc.with_ymd_and_hms(2024, 1, 15, 14, 45, 0).unwrap(); let session = SavedSession { id: "sess-123".to_string(), name: "My Chat".to_string(), created_at: created, last_activity_at: last_activity, working_directory: "/home/naomi/project".to_string(), message_count: 42, preview: "What is the meaning of life?".to_string(), messages: vec![], }; let item = SessionListItem::from(&session); assert_eq!(item.id, "sess-123"); assert_eq!(item.name, "My Chat"); assert_eq!(item.created_at, created); assert_eq!(item.last_activity_at, last_activity); assert_eq!(item.working_directory, "/home/naomi/project"); assert_eq!(item.message_count, 42); assert_eq!(item.preview, "What is the meaning of life?"); } #[test] fn test_saved_session_serialization() { let session = create_test_session("test-1", "Test Session"); let json = serde_json::to_string(&session).expect("Failed to serialize"); let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize"); assert_eq!(parsed.id, session.id); assert_eq!(parsed.name, session.name); assert_eq!(parsed.working_directory, session.working_directory); } #[test] fn test_saved_message_serialization() { let message = create_test_message("msg-1", "Hello!", "user"); let json = serde_json::to_string(&message).expect("Failed to serialize"); let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize"); assert_eq!(parsed.id, message.id); assert_eq!(parsed.content, message.content); assert_eq!(parsed.message_type, "user"); } #[test] fn test_saved_message_with_tool_name() { let message = SavedMessage { id: "msg-tool-1".to_string(), message_type: "tool".to_string(), content: "File read successfully".to_string(), timestamp: Utc::now(), tool_name: Some("Read".to_string()), }; let json = serde_json::to_string(&message).expect("Failed to serialize"); let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize"); assert_eq!(parsed.tool_name, Some("Read".to_string())); } #[test] fn test_session_with_messages_serialization() { let mut session = create_test_session("sess-full", "Full Session"); session.messages = vec![ create_test_message("msg-1", "Hello!", "user"), create_test_message("msg-2", "Hi there!", "assistant"), create_test_message("msg-3", "Read file", "tool"), ]; session.message_count = 3; let json = serde_json::to_string(&session).expect("Failed to serialize"); let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize"); assert_eq!(parsed.messages.len(), 3); assert_eq!(parsed.messages[0].content, "Hello!"); assert_eq!(parsed.messages[1].message_type, "assistant"); assert_eq!(parsed.messages[2].message_type, "tool"); } #[test] fn test_session_list_item_serialization() { let item = SessionListItem { id: "list-item-1".to_string(), name: "Quick Chat".to_string(), created_at: Utc::now(), last_activity_at: Utc::now(), working_directory: "/tmp".to_string(), message_count: 10, preview: "Short preview...".to_string(), }; let json = serde_json::to_string(&item).expect("Failed to serialize"); let parsed: SessionListItem = serde_json::from_str(&json).expect("Failed to deserialize"); assert_eq!(parsed.id, item.id); assert_eq!(parsed.name, item.name); assert_eq!(parsed.preview, item.preview); } #[test] fn test_message_type_field_rename() { // The message_type field is renamed to "type" in JSON let message = create_test_message("msg-1", "Test", "assistant"); let json = serde_json::to_string(&message).expect("Failed to serialize"); assert!(json.contains("\"type\":")); assert!(!json.contains("\"message_type\":")); } #[test] fn test_session_default_empty_messages() { let session = SavedSession { id: "empty".to_string(), name: "Empty".to_string(), created_at: Utc::now(), last_activity_at: Utc::now(), working_directory: "/".to_string(), message_count: 0, preview: "".to_string(), messages: vec![], }; assert!(session.messages.is_empty()); assert_eq!(session.message_count, 0); } #[test] #[allow(clippy::useless_vec)] fn test_session_sorting_by_activity() { let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); let mut sessions = vec![ SessionListItem { id: "old".to_string(), name: "Old Session".to_string(), created_at: old_time, last_activity_at: old_time, working_directory: "/old".to_string(), message_count: 1, preview: "Old".to_string(), }, SessionListItem { id: "new".to_string(), name: "New Session".to_string(), created_at: new_time, last_activity_at: new_time, working_directory: "/new".to_string(), message_count: 1, preview: "New".to_string(), }, ]; // Sort by last activity, most recent first (mimics list_sessions behavior) sessions.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); assert_eq!(sessions[0].id, "new"); assert_eq!(sessions[1].id, "old"); } #[test] fn test_session_clone() { let original = create_test_session("clone-test", "Clone Test"); let cloned = original.clone(); assert_eq!(original.id, cloned.id); assert_eq!(original.name, cloned.name); } #[test] fn test_message_clone() { let original = create_test_message("msg-clone", "Content", "user"); let cloned = original.clone(); assert_eq!(original.id, cloned.id); assert_eq!(original.content, cloned.content); } }