use std::{collections::HashMap, io::Read, path::PathBuf};
use az::SaturatingAs;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::ListItem;
fn activations_path() -> PathBuf {
crate::plugin_data_dir().join("activations.json")
}
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(transparent, default)]
struct AllActivations {
map: HashMap<String, ItemActivations>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ItemActivations {
frequency: u64,
#[serde(with = "time::serde::timestamp")]
last_use: time::OffsetDateTime,
}
fn activations() -> AllActivations {
(|| {
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.read(true)
.open(activations_path())
.inspect_err(|e| eprintln!("error: {e}"))
.ok()?;
let mut buf = Vec::new();
file.read_to_end(&mut buf).ok()?;
serde_json::from_slice(&buf).ok()
})()
.unwrap_or_default()
}
pub(crate) fn register_usage(title: &str) {
let mut current = activations();
let entry = current
.map
.entry(title.to_string())
.or_insert_with(|| ItemActivations {
frequency: 0,
last_use: OffsetDateTime::now_utc(),
});
entry.frequency += 1;
entry.last_use = OffsetDateTime::now_utc();
let Ok(json_string) = serde_json::to_string(¤t) else {
eprintln!("failed stringifying {current:?}");
return;
};
_ = std::fs::write(activations_path(), json_string);
}
#[expect(clippy::unused_async, reason = "may require async in the future")]
pub async fn rank<'iter>(
query: &str,
items: impl IntoIterator<Item = &'iter ListItem>,
weights: Weights,
) -> Vec<ListItem> {
let should_track_history = weights.frequency != 0.0 || weights.recency != 0.0;
let activations = if should_track_history {
self::activations()
} else {
AllActivations::default()
};
let now = OffsetDateTime::now_utc();
#[expect(clippy::cast_precision_loss, reason = "precision isn't needed")]
let mut scored: Vec<_> = items
.into_iter()
.filter_map(|item| {
macro_rules! score {
($field:ident) => {
(weights.$field != 0.0)
.then(|| sublime_fuzzy::best_match(&query, &item.$field))
.flatten()
.map(|m| m.score() as f32 * weights.$field)
.unwrap_or(0.0)
};
}
let title_score = score!(title);
let desc_score = score!(description);
let (freq, elapsed_secs) = activations.map.get(&item.title).map_or(
(0, u64::MAX),
|ItemActivations {
frequency,
last_use,
}| {
(
*frequency,
(now - *last_use).whole_seconds().saturating_as::<u64>(),
)
},
);
let elapsed_min = elapsed_secs / 1000;
let recency = 1.0 / elapsed_min.saturating_add(20) as f32;
let fuzzy_score = title_score + desc_score;
let freq_score =
freq as f32 * weights.frequency * recency * (fuzzy_score / 500.0 + 0.1);
let recency_score = recency * weights.recency;
let total_score = fuzzy_score + freq_score + recency_score;
let should_show = query.is_empty() || fuzzy_score > 1.0;
should_show.then_some((total_score, item))
})
.collect();
scored.sort_by(|(s1, _), (s2, _)| s2.total_cmp(s1));
scored.into_iter().map(|(_, item)| item).cloned().collect()
}
pub struct Weights {
title: f32,
description: f32,
frequency: f32,
recency: f32,
}
impl Weights {
pub fn with_history() -> Self {
Self::without_history().frequency(50.0).recency(500.0)
}
pub fn without_history() -> Self {
Self {
title: 1.0,
description: 0.0,
frequency: 0.0,
recency: 0.0,
}
}
#[must_use = "builder method consumes self"]
pub fn title(mut self, title: f32) -> Self {
self.title = title;
self
}
#[must_use = "builder method consumes self"]
pub fn description(mut self, description: f32) -> Self {
self.description = description;
self
}
#[must_use = "builder method consumes self"]
pub fn frequency(mut self, frequency: f32) -> Self {
self.frequency = frequency;
self
}
#[must_use = "builder method consumes self"]
pub fn recency(mut self, recency: f32) -> Self {
self.recency = recency;
self
}
}