use std::{ collections::HashMap, path::PathBuf, sync::{Arc, Mutex}, time::{SystemTime, UNIX_EPOCH}, }; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] struct Entry { count: u32, last_used: u64, } pub struct FrecencyStore { path: PathBuf, data: Mutex>, } impl FrecencyStore { pub fn new(path: PathBuf) -> Arc { let data = std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); Arc::new(Self { path, data: Mutex::new(data) }) } #[cfg(test)] pub fn new_for_test() -> Arc { Arc::new(Self { path: PathBuf::from("/dev/null"), data: Mutex::new(HashMap::new()), }) } pub fn load() -> Arc { let path = xdg::BaseDirectories::new() .get_data_home() .unwrap_or_else(|| PathBuf::from(".")) .join("k-launcher") .join("frecency.json"); Self::new(path) } pub fn record(&self, id: &str) { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let mut data = self.data.lock().unwrap(); let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 }); entry.count += 1; entry.last_used = now; if let Some(parent) = self.path.parent() { let _ = std::fs::create_dir_all(parent); } if let Ok(json) = serde_json::to_string(&*data) { let _ = std::fs::write(&self.path, json); } } pub fn frecency_score(&self, id: &str) -> u32 { let data = self.data.lock().unwrap(); let Some(entry) = data.get(id) else { return 0 }; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let age_secs = now.saturating_sub(entry.last_used); let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 }; entry.count * decay } pub fn top_ids(&self, n: usize) -> Vec { let data = self.data.lock().unwrap(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let mut scored: Vec<(String, u32)> = data .iter() .map(|(id, entry)| { let age_secs = now.saturating_sub(entry.last_used); let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 }; (id.clone(), entry.count * decay) }) .collect(); scored.sort_by(|a, b| b.1.cmp(&a.1)); scored.into_iter().take(n).map(|(id, _)| id).collect() } } #[cfg(test)] mod tests { use super::*; fn make_store() -> Arc { Arc::new(FrecencyStore { path: PathBuf::from("/dev/null"), data: Mutex::new(HashMap::new()), }) } #[test] fn record_increments_count() { let store = make_store(); store.record("app-firefox"); store.record("app-firefox"); let data = store.data.lock().unwrap(); assert_eq!(data["app-firefox"].count, 2); } #[test] fn record_updates_last_used() { let store = make_store(); store.record("app-firefox"); let data = store.data.lock().unwrap(); assert!(data["app-firefox"].last_used > 0); } #[test] fn top_ids_returns_sorted_order() { let store = make_store(); store.record("app-firefox"); store.record("app-code"); store.record("app-code"); store.record("app-code"); let top = store.top_ids(2); assert_eq!(top[0], "app-code"); assert_eq!(top[1], "app-firefox"); } }