135 lines
3.9 KiB
Rust
135 lines
3.9 KiB
Rust
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<HashMap<String, Entry>>,
|
|
}
|
|
|
|
impl FrecencyStore {
|
|
pub fn new(path: PathBuf) -> Arc<Self> {
|
|
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<Self> {
|
|
Arc::new(Self {
|
|
path: PathBuf::from("/dev/null"),
|
|
data: Mutex::new(HashMap::new()),
|
|
})
|
|
}
|
|
|
|
pub fn load() -> Arc<Self> {
|
|
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<String> {
|
|
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<FrecencyStore> {
|
|
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");
|
|
}
|
|
}
|