use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const FRECENCY_CAP: usize = 1000;
const HALF_LIFE_SECS: f64 = 7.0 * 24.0 * 60.0 * 60.0;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FrecencyRecord {
path: String,
count: u32,
last_used: u64,
}
#[derive(Debug, Default)]
struct Store {
by_path: HashMap<String, FrecencyRecord>,
persisted_path: Option<PathBuf>,
loaded: bool,
}
fn store() -> &'static Mutex<Store> {
static STORE: OnceLock<Mutex<Store>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(Store::default()))
}
fn default_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".deepseek").join("file-frecency.jsonl"))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn decayed_score(record: &FrecencyRecord, now: u64) -> f64 {
let age_secs = now.saturating_sub(record.last_used) as f64;
let lambda = std::f64::consts::LN_2 / HALF_LIFE_SECS;
(record.count as f64) * (-lambda * age_secs).exp()
}
fn ensure_loaded(store: &mut Store) {
if store.loaded {
return;
}
store.loaded = true;
let Some(path) = default_path() else {
return;
};
store.persisted_path = Some(path.clone());
let Ok(text) = std::fs::read_to_string(&path) else {
return;
};
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let Ok(record) = serde_json::from_str::<FrecencyRecord>(line) else {
continue;
};
store.by_path.insert(record.path.clone(), record);
}
}
fn evict_to_cap(store: &mut Store, now: u64) {
if store.by_path.len() <= FRECENCY_CAP {
return;
}
let target = FRECENCY_CAP;
let mut scored: Vec<(String, f64)> = store
.by_path
.iter()
.map(|(k, v)| (k.clone(), decayed_score(v, now)))
.collect();
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let drop_count = store.by_path.len().saturating_sub(target);
for (key, _) in scored.iter().take(drop_count) {
store.by_path.remove(key);
}
}
fn append_record_line(path: &PathBuf, record: &FrecencyRecord) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let line = serde_json::to_string(record).map_err(std::io::Error::other)?;
writeln!(file, "{line}")?;
Ok(())
}
pub fn record_mention(path: &str) {
if path.is_empty() {
return;
}
let store = store();
let Ok(mut store) = store.lock() else {
return;
};
ensure_loaded(&mut store);
let now = now_secs();
let entry = store
.by_path
.entry(path.to_string())
.or_insert_with(|| FrecencyRecord {
path: path.to_string(),
count: 0,
last_used: now,
});
entry.count = entry.count.saturating_add(1);
entry.last_used = now;
let snapshot = entry.clone();
if let Some(persisted_path) = store.persisted_path.clone()
&& let Err(err) = append_record_line(&persisted_path, &snapshot)
{
tracing::debug!(target: "frecency", "persist failed: {err}");
}
evict_to_cap(&mut store, now);
}
#[must_use]
pub fn rerank_by_frecency(candidates: Vec<String>) -> Vec<String> {
if candidates.len() <= 1 {
return candidates;
}
let store = store();
let Ok(mut store) = store.lock() else {
return candidates;
};
ensure_loaded(&mut store);
let now = now_secs();
let mut scored: Vec<(usize, String, f64)> = candidates
.into_iter()
.enumerate()
.map(|(idx, path)| {
let score = store
.by_path
.get(&path)
.map(|r| decayed_score(r, now))
.unwrap_or(0.0);
(idx, path, score)
})
.collect();
scored.sort_by(|a, b| {
b.2.partial_cmp(&a.2)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
scored.into_iter().map(|(_, path, _)| path).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rerank_floats_recent_paths_to_the_top() {
let store = super::store();
let mut s = store.lock().unwrap();
s.by_path.clear();
s.loaded = true; s.persisted_path = None; let now = super::now_secs();
s.by_path.insert(
"src/popular.rs".into(),
FrecencyRecord {
path: "src/popular.rs".into(),
count: 8,
last_used: now,
},
);
drop(s);
let order = super::rerank_by_frecency(vec![
"README.md".to_string(),
"src/popular.rs".to_string(),
"Cargo.toml".to_string(),
]);
assert_eq!(order[0], "src/popular.rs");
assert_eq!(order[1], "README.md");
assert_eq!(order[2], "Cargo.toml");
}
#[test]
fn old_entries_decay_below_recent_ones() {
let now: u64 = 7 * 24 * 60 * 60 * 8; let stale = FrecencyRecord {
path: "x".into(),
count: 50,
last_used: 0,
};
let fresh = FrecencyRecord {
path: "y".into(),
count: 2,
last_used: now,
};
assert!(
super::decayed_score(&fresh, now) > super::decayed_score(&stale, now),
"fresh={}, stale={}",
super::decayed_score(&fresh, now),
super::decayed_score(&stale, now)
);
}
}