use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; use std::hash::{Hash, Hasher}; /// Tools that could benefit from caching #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CacheableTool { Read, Glob, Grep, } impl CacheableTool { #[allow(dead_code)] pub fn from_name(name: &str) -> Option { match name { "Read" => Some(Self::Read), "Glob" => Some(Self::Glob), "Grep" => Some(Self::Grep), _ => None, } } } /// Statistics about potential cache savings #[allow(dead_code)] #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct CacheAnalytics { /// Number of tool calls that could have been cache hits pub potential_cache_hits: u64, /// Estimated tokens that could have been saved pub potential_savings_tokens: u64, /// Tracks unique tool invocations: hash -> (tool_name, call_count) #[serde(skip)] recent_invocations: HashMap, } #[allow(dead_code)] impl CacheAnalytics { pub fn new() -> Self { Self::default() } /// Compute a hash key from tool name and input fn compute_key(tool_name: &str, input: &serde_json::Value) -> u64 { let mut hasher = DefaultHasher::new(); tool_name.hash(&mut hasher); input.to_string().hash(&mut hasher); hasher.finish() } /// Track a tool invocation for analytics /// Returns true if this was a repeated invocation (potential cache hit) pub fn track_invocation( &mut self, tool_name: &str, input: &serde_json::Value, estimated_tokens: u64, ) -> bool { // Only track cacheable tools if CacheableTool::from_name(tool_name).is_none() { return false; } let key = Self::compute_key(tool_name, input); if let Some((_, count)) = self.recent_invocations.get_mut(&key) { *count += 1; // This is a repeat - could have been a cache hit self.potential_cache_hits += 1; self.potential_savings_tokens += estimated_tokens; true } else { self.recent_invocations .insert(key, (tool_name.to_string(), 1)); false } } /// Get the number of unique tool invocations being tracked pub fn unique_invocations(&self) -> usize { self.recent_invocations.len() } /// Get invocations that were called more than once pub fn repeated_invocations(&self) -> Vec<(&str, u64)> { self.recent_invocations .values() .filter(|(_, count)| *count > 1) .map(|(name, count)| (name.as_str(), *count)) .collect() } /// Clear session analytics (keep totals) pub fn clear_session(&mut self) { self.recent_invocations.clear(); } /// Fully reset all analytics pub fn reset(&mut self) { self.potential_cache_hits = 0; self.potential_savings_tokens = 0; self.recent_invocations.clear(); } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn test_cacheable_tool_from_name() { assert_eq!(CacheableTool::from_name("Read"), Some(CacheableTool::Read)); assert_eq!(CacheableTool::from_name("Glob"), Some(CacheableTool::Glob)); assert_eq!(CacheableTool::from_name("Grep"), Some(CacheableTool::Grep)); assert_eq!(CacheableTool::from_name("Bash"), None); assert_eq!(CacheableTool::from_name("Edit"), None); assert_eq!(CacheableTool::from_name("Write"), None); } #[test] fn test_first_invocation_not_cache_hit() { let mut analytics = CacheAnalytics::new(); let input = json!({"file_path": "/home/test/file.txt"}); let is_repeat = analytics.track_invocation("Read", &input, 100); assert!(!is_repeat); assert_eq!(analytics.potential_cache_hits, 0); assert_eq!(analytics.potential_savings_tokens, 0); } #[test] fn test_second_invocation_is_cache_hit() { let mut analytics = CacheAnalytics::new(); let input = json!({"file_path": "/home/test/file.txt"}); analytics.track_invocation("Read", &input, 100); let is_repeat = analytics.track_invocation("Read", &input, 100); assert!(is_repeat); assert_eq!(analytics.potential_cache_hits, 1); assert_eq!(analytics.potential_savings_tokens, 100); } #[test] fn test_different_inputs_not_cache_hit() { let mut analytics = CacheAnalytics::new(); let input1 = json!({"file_path": "/home/test/file1.txt"}); let input2 = json!({"file_path": "/home/test/file2.txt"}); analytics.track_invocation("Read", &input1, 100); let is_repeat = analytics.track_invocation("Read", &input2, 100); assert!(!is_repeat); assert_eq!(analytics.potential_cache_hits, 0); } #[test] fn test_non_cacheable_tool_ignored() { let mut analytics = CacheAnalytics::new(); let input = json!({"command": "ls -la"}); let is_repeat = analytics.track_invocation("Bash", &input, 100); analytics.track_invocation("Bash", &input, 100); assert!(!is_repeat); assert_eq!(analytics.potential_cache_hits, 0); assert_eq!(analytics.unique_invocations(), 0); } #[test] fn test_multiple_repeated_invocations() { let mut analytics = CacheAnalytics::new(); let input = json!({"file_path": "/home/test/file.txt"}); analytics.track_invocation("Read", &input, 100); analytics.track_invocation("Read", &input, 100); analytics.track_invocation("Read", &input, 100); assert_eq!(analytics.potential_cache_hits, 2); assert_eq!(analytics.potential_savings_tokens, 200); } #[test] fn test_unique_invocations_count() { let mut analytics = CacheAnalytics::new(); analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100); analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100); analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50); assert_eq!(analytics.unique_invocations(), 3); } #[test] fn test_repeated_invocations_list() { let mut analytics = CacheAnalytics::new(); // file1 read twice analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100); analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100); // file2 read once analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100); // glob run 3 times analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50); analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50); analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50); let repeated = analytics.repeated_invocations(); assert_eq!(repeated.len(), 2); // file1 and glob pattern } #[test] fn test_clear_session() { let mut analytics = CacheAnalytics::new(); let input = json!({"file_path": "/file.txt"}); analytics.track_invocation("Read", &input, 100); analytics.track_invocation("Read", &input, 100); assert_eq!(analytics.potential_cache_hits, 1); assert_eq!(analytics.unique_invocations(), 1); analytics.clear_session(); assert_eq!(analytics.potential_cache_hits, 1); // Preserved assert_eq!(analytics.unique_invocations(), 0); // Cleared } #[test] fn test_reset() { let mut analytics = CacheAnalytics::new(); let input = json!({"file_path": "/file.txt"}); analytics.track_invocation("Read", &input, 100); analytics.track_invocation("Read", &input, 100); analytics.reset(); assert_eq!(analytics.potential_cache_hits, 0); assert_eq!(analytics.potential_savings_tokens, 0); assert_eq!(analytics.unique_invocations(), 0); } #[test] fn test_serialization() { let mut analytics = CacheAnalytics::new(); analytics.potential_cache_hits = 10; analytics.potential_savings_tokens = 500; let json = serde_json::to_string(&analytics).expect("Failed to serialize"); let deserialized: CacheAnalytics = serde_json::from_str(&json).expect("Failed to deserialize"); assert_eq!(deserialized.potential_cache_hits, 10); assert_eq!(deserialized.potential_savings_tokens, 500); // recent_invocations is skipped in serialization assert_eq!(deserialized.unique_invocations(), 0); } }