// Clipboard history module for tracking and managing copied code snippets // Implements issue #25 - Clipboard History feature use serde::{Deserialize, Serialize}; use std::sync::Mutex; use tauri_plugin_store::StoreExt; use uuid::Uuid; const STORE_FILE: &str = "hikari-clipboard.json"; const HISTORY_KEY: &str = "clipboard_history"; const MAX_HISTORY_SIZE: usize = 100; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClipboardEntry { pub id: String, pub content: String, pub language: Option, pub source: Option, pub timestamp: String, pub is_pinned: bool, } impl ClipboardEntry { pub fn new(content: String, language: Option, source: Option) -> Self { Self { id: Uuid::new_v4().to_string(), content, language, source, timestamp: chrono::Utc::now().to_rfc3339(), is_pinned: false, } } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct ClipboardHistory { entries: Vec, } // Track last clipboard content to avoid duplicates #[derive(Default)] struct ClipboardState { last_content: Option, } static CLIPBOARD_STATE: Mutex = Mutex::new(ClipboardState { last_content: None }); fn load_history(app: &tauri::AppHandle) -> ClipboardHistory { let store = app.store(STORE_FILE).ok(); store .and_then(|s| s.get(HISTORY_KEY)) .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default() } fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> { let store = app.store(STORE_FILE).map_err(|e| e.to_string())?; store.set( HISTORY_KEY, serde_json::to_value(history).map_err(|e| e.to_string())?, ); store.save().map_err(|e| e.to_string())?; Ok(()) } /// List all clipboard entries, optionally filtered by language #[tauri::command] pub fn list_clipboard_entries( app: tauri::AppHandle, language: Option, ) -> Result, String> { let history = load_history(&app); let entries = if let Some(lang) = language { history .entries .into_iter() .filter(|e| e.language.as_ref() == Some(&lang)) .collect() } else { history.entries }; Ok(entries) } /// Capture current clipboard content and add to history #[tauri::command] pub fn capture_clipboard( app: tauri::AppHandle, content: String, language: Option, source: Option, ) -> Result { // Check for duplicate (same content as last capture) { let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?; if state.last_content.as_ref() == Some(&content) { // Return existing entry if content is the same let history = load_history(&app); if let Some(entry) = history.entries.first() { if entry.content == content { return Ok(entry.clone()); } } } state.last_content = Some(content.clone()); } let entry = ClipboardEntry::new(content, language, source); let mut history = load_history(&app); // Add to front of history history.entries.insert(0, entry.clone()); // Enforce max size (keep pinned entries) let mut pinned: Vec = history .entries .iter() .filter(|e| e.is_pinned) .cloned() .collect(); let mut unpinned: Vec = history .entries .into_iter() .filter(|e| !e.is_pinned) .collect(); // Trim unpinned entries if over max size if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE { let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len()); unpinned.truncate(max_unpinned); } // Merge back, pinned first then unpinned pinned.extend(unpinned); history.entries = pinned; // Sort by timestamp descending (newest first), pinned entries stay at top history.entries.sort_by(|a, b| { if a.is_pinned && !b.is_pinned { std::cmp::Ordering::Less } else if !a.is_pinned && b.is_pinned { std::cmp::Ordering::Greater } else { b.timestamp.cmp(&a.timestamp) } }); save_history(&app, &history)?; Ok(entry) } /// Delete a clipboard entry by ID #[tauri::command] pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> { let mut history = load_history(&app); history.entries.retain(|e| e.id != id); save_history(&app, &history)?; Ok(()) } /// Toggle pin status of an entry #[tauri::command] pub fn toggle_pin_clipboard_entry( app: tauri::AppHandle, id: String, ) -> Result { let mut history = load_history(&app); let entry = history .entries .iter_mut() .find(|e| e.id == id) .ok_or("Entry not found")?; entry.is_pinned = !entry.is_pinned; let updated_entry = entry.clone(); // Re-sort to move pinned entries to top history.entries.sort_by(|a, b| { if a.is_pinned && !b.is_pinned { std::cmp::Ordering::Less } else if !a.is_pinned && b.is_pinned { std::cmp::Ordering::Greater } else { b.timestamp.cmp(&a.timestamp) } }); save_history(&app, &history)?; Ok(updated_entry) } /// Clear all non-pinned entries #[tauri::command] pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> { let mut history = load_history(&app); history.entries.retain(|e| e.is_pinned); save_history(&app, &history)?; Ok(()) } /// Search clipboard entries by content #[tauri::command] pub fn search_clipboard_entries( app: tauri::AppHandle, query: String, ) -> Result, String> { let history = load_history(&app); let query_lower = query.to_lowercase(); let entries = history .entries .into_iter() .filter(|e| { e.content.to_lowercase().contains(&query_lower) || e.language .as_ref() .is_some_and(|l| l.to_lowercase().contains(&query_lower)) || e.source .as_ref() .is_some_and(|s| s.to_lowercase().contains(&query_lower)) }) .collect(); Ok(entries) } /// Get all unique languages from history #[tauri::command] pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result, String> { let history = load_history(&app); let mut languages: Vec = history .entries .iter() .filter_map(|e| e.language.clone()) .collect(); languages.sort(); languages.dedup(); Ok(languages) } /// Update the language of an entry #[tauri::command] pub fn update_clipboard_language( app: tauri::AppHandle, id: String, language: Option, ) -> Result { let mut history = load_history(&app); let entry = history .entries .iter_mut() .find(|e| e.id == id) .ok_or("Entry not found")?; entry.language = language; let updated_entry = entry.clone(); save_history(&app, &history)?; Ok(updated_entry) } #[cfg(test)] mod tests { use super::*; // ==================== ClipboardEntry tests ==================== #[test] fn test_clipboard_entry_new() { let entry = ClipboardEntry::new( "let x = 42;".to_string(), Some("rust".to_string()), Some("main.rs".to_string()), ); assert_eq!(entry.content, "let x = 42;"); assert_eq!(entry.language, Some("rust".to_string())); assert_eq!(entry.source, Some("main.rs".to_string())); assert!(!entry.is_pinned); assert!(!entry.id.is_empty()); assert!(!entry.timestamp.is_empty()); } #[test] fn test_clipboard_entry_new_without_optional_fields() { let entry = ClipboardEntry::new("some content".to_string(), None, None); assert_eq!(entry.content, "some content"); assert!(entry.language.is_none()); assert!(entry.source.is_none()); assert!(!entry.is_pinned); } #[test] fn test_clipboard_entry_unique_ids() { let entry1 = ClipboardEntry::new("content1".to_string(), None, None); let entry2 = ClipboardEntry::new("content2".to_string(), None, None); assert_ne!(entry1.id, entry2.id); } #[test] fn test_clipboard_entry_serialization() { let entry = ClipboardEntry::new( "fn main() {}".to_string(), Some("rust".to_string()), Some("lib.rs".to_string()), ); let json = serde_json::to_string(&entry).unwrap(); assert!(json.contains("fn main() {}")); assert!(json.contains("rust")); assert!(json.contains("lib.rs")); assert!(json.contains("is_pinned")); let deserialized: ClipboardEntry = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.content, entry.content); assert_eq!(deserialized.language, entry.language); assert_eq!(deserialized.source, entry.source); assert_eq!(deserialized.id, entry.id); } #[test] fn test_clipboard_entry_clone() { let entry = ClipboardEntry::new( "original".to_string(), Some("python".to_string()), None, ); let cloned = entry.clone(); assert_eq!(cloned.content, entry.content); assert_eq!(cloned.id, entry.id); assert_eq!(cloned.language, entry.language); } #[test] fn test_clipboard_entry_timestamp_is_rfc3339() { let entry = ClipboardEntry::new("test".to_string(), None, None); // RFC3339 timestamp should parse successfully let parsed = chrono::DateTime::parse_from_rfc3339(&entry.timestamp); assert!(parsed.is_ok()); } // ==================== ClipboardHistory tests ==================== #[test] fn test_clipboard_history_default() { let history = ClipboardHistory::default(); assert!(history.entries.is_empty()); } #[test] fn test_clipboard_history_serialization() { let mut history = ClipboardHistory::default(); history.entries.push(ClipboardEntry::new( "entry1".to_string(), Some("js".to_string()), None, )); history.entries.push(ClipboardEntry::new( "entry2".to_string(), None, Some("file.txt".to_string()), )); let json = serde_json::to_string(&history).unwrap(); assert!(json.contains("entry1")); assert!(json.contains("entry2")); assert!(json.contains("js")); assert!(json.contains("file.txt")); let deserialized: ClipboardHistory = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.entries.len(), 2); } #[test] fn test_clipboard_history_entries_order() { let mut history = ClipboardHistory::default(); history.entries.push(ClipboardEntry::new("first".to_string(), None, None)); history.entries.push(ClipboardEntry::new("second".to_string(), None, None)); history.entries.push(ClipboardEntry::new("third".to_string(), None, None)); assert_eq!(history.entries[0].content, "first"); assert_eq!(history.entries[1].content, "second"); assert_eq!(history.entries[2].content, "third"); } // ==================== ClipboardState tests ==================== #[test] fn test_clipboard_state_default() { let state = ClipboardState::default(); assert!(state.last_content.is_none()); } #[test] fn test_clipboard_state_with_content() { let state = ClipboardState { last_content: Some("cached content".to_string()), }; assert_eq!(state.last_content, Some("cached content".to_string())); } // ==================== MAX_HISTORY_SIZE constant test ==================== #[test] fn test_max_history_size_is_reasonable() { assert_eq!(MAX_HISTORY_SIZE, 100); // Compile-time assertions for constant bounds const _: () = assert!(MAX_HISTORY_SIZE > 0); const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check } // ==================== Pinned entry sorting tests ==================== #[test] #[allow(clippy::useless_vec)] fn test_pinned_entries_sorting() { let mut entries = vec![ ClipboardEntry { id: "1".to_string(), content: "unpinned older".to_string(), language: None, source: None, timestamp: "2024-01-01T00:00:00Z".to_string(), is_pinned: false, }, ClipboardEntry { id: "2".to_string(), content: "pinned".to_string(), language: None, source: None, timestamp: "2024-01-02T00:00:00Z".to_string(), is_pinned: true, }, ClipboardEntry { id: "3".to_string(), content: "unpinned newer".to_string(), language: None, source: None, timestamp: "2024-01-03T00:00:00Z".to_string(), is_pinned: false, }, ]; // Apply the same sorting logic as used in the module entries.sort_by(|a, b| { if a.is_pinned && !b.is_pinned { std::cmp::Ordering::Less } else if !a.is_pinned && b.is_pinned { std::cmp::Ordering::Greater } else { b.timestamp.cmp(&a.timestamp) } }); // Pinned should be first assert!(entries[0].is_pinned); assert_eq!(entries[0].id, "2"); // Then unpinned sorted by timestamp descending (newest first) assert_eq!(entries[1].id, "3"); // newer unpinned assert_eq!(entries[2].id, "1"); // older unpinned } #[test] #[allow(clippy::useless_vec)] fn test_multiple_pinned_entries_sorting() { let mut entries = vec![ ClipboardEntry { id: "1".to_string(), content: "pinned older".to_string(), language: None, source: None, timestamp: "2024-01-01T00:00:00Z".to_string(), is_pinned: true, }, ClipboardEntry { id: "2".to_string(), content: "unpinned".to_string(), language: None, source: None, timestamp: "2024-01-02T00:00:00Z".to_string(), is_pinned: false, }, ClipboardEntry { id: "3".to_string(), content: "pinned newer".to_string(), language: None, source: None, timestamp: "2024-01-03T00:00:00Z".to_string(), is_pinned: true, }, ]; entries.sort_by(|a, b| { if a.is_pinned && !b.is_pinned { std::cmp::Ordering::Less } else if !a.is_pinned && b.is_pinned { std::cmp::Ordering::Greater } else { b.timestamp.cmp(&a.timestamp) } }); // Both pinned first, sorted by timestamp assert!(entries[0].is_pinned); assert_eq!(entries[0].id, "3"); // pinned newer assert!(entries[1].is_pinned); assert_eq!(entries[1].id, "1"); // pinned older // Then unpinned assert!(!entries[2].is_pinned); assert_eq!(entries[2].id, "2"); } // ==================== Entry filtering tests ==================== #[test] fn test_filter_entries_by_language() { let history = ClipboardHistory { entries: vec![ ClipboardEntry { id: "1".to_string(), content: "rust code".to_string(), language: Some("rust".to_string()), source: None, timestamp: "2024-01-01T00:00:00Z".to_string(), is_pinned: false, }, ClipboardEntry { id: "2".to_string(), content: "js code".to_string(), language: Some("javascript".to_string()), source: None, timestamp: "2024-01-02T00:00:00Z".to_string(), is_pinned: false, }, ClipboardEntry { id: "3".to_string(), content: "more rust".to_string(), language: Some("rust".to_string()), source: None, timestamp: "2024-01-03T00:00:00Z".to_string(), is_pinned: false, }, ], }; let filtered: Vec<_> = history .entries .iter() .filter(|e| e.language.as_ref() == Some(&"rust".to_string())) .collect(); assert_eq!(filtered.len(), 2); assert!(filtered.iter().all(|e| e.language == Some("rust".to_string()))); } #[test] fn test_search_entries_by_content() { let history = ClipboardHistory { entries: vec![ ClipboardEntry { id: "1".to_string(), content: "fn hello_world()".to_string(), language: Some("rust".to_string()), source: None, timestamp: "2024-01-01T00:00:00Z".to_string(), is_pinned: false, }, ClipboardEntry { id: "2".to_string(), content: "function hello()".to_string(), language: Some("javascript".to_string()), source: None, timestamp: "2024-01-02T00:00:00Z".to_string(), is_pinned: false, }, ClipboardEntry { id: "3".to_string(), content: "def goodbye()".to_string(), language: Some("python".to_string()), source: None, timestamp: "2024-01-03T00:00:00Z".to_string(), is_pinned: false, }, ], }; let query = "hello"; let query_lower = query.to_lowercase(); let filtered: Vec<_> = history .entries .iter() .filter(|e| e.content.to_lowercase().contains(&query_lower)) .collect(); assert_eq!(filtered.len(), 2); assert!(filtered[0].content.contains("hello")); assert!(filtered[1].content.contains("hello")); } #[test] fn test_search_entries_case_insensitive() { let history = ClipboardHistory { entries: vec![ ClipboardEntry { id: "1".to_string(), content: "HELLO WORLD".to_string(), language: None, source: None, timestamp: "2024-01-01T00:00:00Z".to_string(), is_pinned: false, }, ], }; let query = "hello"; let query_lower = query.to_lowercase(); let filtered: Vec<_> = history .entries .iter() .filter(|e| e.content.to_lowercase().contains(&query_lower)) .collect(); assert_eq!(filtered.len(), 1); } // ==================== Unique languages extraction test ==================== #[test] fn test_extract_unique_languages() { let history = ClipboardHistory { entries: vec![ ClipboardEntry { id: "1".to_string(), content: "".to_string(), language: Some("rust".to_string()), source: None, timestamp: "".to_string(), is_pinned: false, }, ClipboardEntry { id: "2".to_string(), content: "".to_string(), language: Some("javascript".to_string()), source: None, timestamp: "".to_string(), is_pinned: false, }, ClipboardEntry { id: "3".to_string(), content: "".to_string(), language: Some("rust".to_string()), // Duplicate source: None, timestamp: "".to_string(), is_pinned: false, }, ClipboardEntry { id: "4".to_string(), content: "".to_string(), language: None, // No language source: None, timestamp: "".to_string(), is_pinned: false, }, ], }; let mut languages: Vec = history .entries .iter() .filter_map(|e| e.language.clone()) .collect(); languages.sort(); languages.dedup(); assert_eq!(languages.len(), 2); assert!(languages.contains(&"rust".to_string())); assert!(languages.contains(&"javascript".to_string())); } // ==================== Retain pinned entries test ==================== #[test] fn test_retain_pinned_on_clear() { let mut history = ClipboardHistory { entries: vec![ ClipboardEntry { id: "1".to_string(), content: "pinned".to_string(), language: None, source: None, timestamp: "".to_string(), is_pinned: true, }, ClipboardEntry { id: "2".to_string(), content: "unpinned".to_string(), language: None, source: None, timestamp: "".to_string(), is_pinned: false, }, ClipboardEntry { id: "3".to_string(), content: "another pinned".to_string(), language: None, source: None, timestamp: "".to_string(), is_pinned: true, }, ], }; // Simulate clear (keep only pinned) history.entries.retain(|e| e.is_pinned); assert_eq!(history.entries.len(), 2); assert!(history.entries.iter().all(|e| e.is_pinned)); } }