Files
owlry/crates/owlry-core/src/data/frecency.rs
vikingowl 7275fcab35 fix: implement all 24 FIX_PLAN issues across 6 phases
Phase 1 — Critical Safety:
- #11: bounded IPC reads via read_bounded_line (server + client)
- #13: sound Send+Sync via Arc<Mutex<RuntimeHandle>>; remove unsafe impl Sync
- #10: ItemSource enum (Core/NativePlugin/ScriptPlugin) on LaunchItem;
  script plugin allowlist guard in launch_item()

Phase 2 — Config System Overhaul:
- #6: remove dead enabled_plugins field
- #1: replace #[serde(flatten)] with explicit Config::plugin_config
- #4: Server.config Arc<RwLock<Config>>; ConfigProvider shares same Arc
- #2/#3: atomic config save (temp+rename); TOCTOU fixed — write lock held
  across mutation and save() in config_editor
- #23: fs2 lock_exclusive() on .lock sidecar file in Config::save()
- #16: SIGHUP handler reloads config; ExecReload in systemd service

Phase 3 — Plugin Architecture:
- #7: HostAPI v4 with get_config_string/int/bool; PLUGIN_CONFIG OnceLock
  in native_loader, set_shared_config() called from Server::bind()
- #5: PluginEntry + Request::PluginList + Response::PluginList; plugin_registry
  in ProviderManager tracks active and suppressed native plugins;
  cmd_list_installed shows both script and native plugins
- #9: suppressed native plugin log level info! → warn!
- #8: ProviderType doc glossary; plugins/mod.rs terminology table

Phase 4 — Data Integrity:
- #12: all into_inner() in server.rs + providers/mod.rs → explicit Response::Error;
  watcher exits on poisoned lock
- #14: FrecencyStore::prune() (180-day age + 5000-entry cap) called on load
- #17: empty command guard in launch_item(); warn in lua_provider
- #24: 5-min periodic frecency save thread; SIGTERM/SIGINT saves frecency
  before exit (replaces ctrlc handler)

Phase 5 — UI & UX:
- #19: provider_meta.rs ProviderMeta + meta_for(); three match blocks collapsed
- #18: desktop file dedup via seen_basenames HashSet in ApplicationProvider
- #20: search_filtered gains tag_filter param; non-frecency path now filters
- #15: widget refresh 5s→10s; skip when user is typing

Phase 6 — Hardening:
- #22: catch_unwind removed from reload_runtimes(); direct drop()
- #21: AtomicUsize + RAII ConnectionGuard; MAX_CONNECTIONS = 16

Deps: add fs2 = "0.4"; remove ctrlc and toml_edit from owlry-core
2026-04-08 16:43:52 +02:00

311 lines
9.1 KiB
Rust

use chrono::{DateTime, Utc};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::paths;
/// A single frecency entry tracking launch count and recency
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyEntry {
pub launch_count: u32,
pub last_launch: DateTime<Utc>,
}
/// Persistent frecency data store
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyData {
pub version: u32,
pub entries: HashMap<String, FrecencyEntry>,
}
impl Default for FrecencyData {
fn default() -> Self {
Self {
version: 1,
entries: HashMap::new(),
}
}
}
const MAX_ENTRIES: usize = 5000;
const PRUNE_AGE_DAYS: i64 = 180;
const MIN_LAUNCHES_TO_KEEP: u32 = 3;
/// Frecency store for tracking and boosting recently/frequently used items
pub struct FrecencyStore {
data: FrecencyData,
path: PathBuf,
dirty: bool,
}
impl FrecencyStore {
/// Create a new frecency store, loading existing data if available
pub fn new() -> Self {
let path = Self::data_path();
let data = Self::load_from_path(&path).unwrap_or_default();
info!("Frecency store loaded with {} entries", data.entries.len());
let mut store = Self {
data,
path,
dirty: false,
};
store.prune();
store
}
/// Remove stale low-usage entries and enforce the hard cap.
///
/// Entries older than `PRUNE_AGE_DAYS` with fewer than `MIN_LAUNCHES_TO_KEEP`
/// launches are removed. After age-based pruning, entries are sorted by score
/// (descending) and the list is truncated to `MAX_ENTRIES`.
fn prune(&mut self) {
let now = Utc::now();
let cutoff = now - chrono::Duration::days(PRUNE_AGE_DAYS);
let before = self.data.entries.len();
self.data.entries.retain(|_, e| {
e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP
});
if self.data.entries.len() > MAX_ENTRIES {
// Sort by score descending and keep the top MAX_ENTRIES
let mut scored: Vec<(String, f64)> = self
.data
.entries
.iter()
.map(|(k, e)| {
(k.clone(), Self::calculate_frecency_at(e.launch_count, e.last_launch, now))
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let keep: std::collections::HashSet<String> =
scored.into_iter().take(MAX_ENTRIES).map(|(k, _)| k).collect();
self.data.entries.retain(|k, _| keep.contains(k));
}
let removed = before - self.data.entries.len();
if removed > 0 {
info!("Frecency: pruned {} stale entries ({} remaining)", removed, self.data.entries.len());
self.dirty = true;
}
}
/// Alias for new() - loads from disk or creates default
pub fn load_or_default() -> Self {
Self::new()
}
/// Get the path to the frecency data file
fn data_path() -> PathBuf {
paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json"))
}
/// Load frecency data from a file
fn load_from_path(path: &PathBuf) -> Option<FrecencyData> {
if !path.exists() {
debug!("Frecency file not found at {:?}", path);
return None;
}
let content = std::fs::read_to_string(path).ok()?;
match serde_json::from_str(&content) {
Ok(data) => Some(data),
Err(e) => {
warn!("Failed to parse frecency data: {}", e);
None
}
}
}
/// Save frecency data to disk
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if !self.dirty {
return Ok(());
}
paths::ensure_parent_dir(&self.path)?;
let content = serde_json::to_string_pretty(&self.data)?;
std::fs::write(&self.path, content)?;
self.dirty = false;
debug!("Frecency data saved to {:?}", self.path);
Ok(())
}
/// Record a launch event for an item
pub fn record_launch(&mut self, item_id: &str) {
let now = Utc::now();
let entry = self
.data
.entries
.entry(item_id.to_string())
.or_insert(FrecencyEntry {
launch_count: 0,
last_launch: now,
});
entry.launch_count += 1;
entry.last_launch = now;
self.dirty = true;
debug!(
"Recorded launch for '{}': count={}, last={}",
item_id, entry.launch_count, entry.last_launch
);
}
/// Calculate frecency score for an item
/// Uses Firefox-style algorithm: score = launch_count * recency_weight
pub fn get_score(&self, item_id: &str) -> f64 {
match self.data.entries.get(item_id) {
Some(entry) => Self::calculate_frecency(entry.launch_count, entry.last_launch),
None => 0.0,
}
}
/// Calculate frecency score using a pre-sampled timestamp.
/// Use this in hot loops to avoid repeated Utc::now() syscalls.
pub fn get_score_at(&self, item_id: &str, now: DateTime<Utc>) -> f64 {
match self.data.entries.get(item_id) {
Some(entry) => Self::calculate_frecency_at(entry.launch_count, entry.last_launch, now),
None => 0.0,
}
}
/// Calculate frecency using Firefox-style algorithm
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
let now = Utc::now();
Self::calculate_frecency_at(launch_count, last_launch, now)
}
/// Calculate frecency using a caller-provided timestamp.
fn calculate_frecency_at(launch_count: u32, last_launch: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
let age = now.signed_duration_since(last_launch);
let age_days = age.num_hours() as f64 / 24.0;
let recency_weight = if age_days < 1.0 {
100.0
} else if age_days < 7.0 {
70.0
} else if age_days < 30.0 {
50.0
} else if age_days < 90.0 {
30.0
} else {
10.0
};
launch_count as f64 * recency_weight
}
/// Get all entries (for debugging/display)
#[allow(dead_code)]
pub fn entries(&self) -> &HashMap<String, FrecencyEntry> {
&self.data.entries
}
/// Clear all frecency data
#[allow(dead_code)]
pub fn clear(&mut self) {
self.data.entries.clear();
self.dirty = true;
}
}
impl Default for FrecencyStore {
fn default() -> Self {
Self::new()
}
}
impl Drop for FrecencyStore {
fn drop(&mut self) {
// Attempt to save on drop
if let Err(e) = self.save() {
warn!("Failed to save frecency data on drop: {}", e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frecency_calculation() {
let now = Utc::now();
// Recent launch should have high score
let score_today = FrecencyStore::calculate_frecency(10, now);
assert!(score_today > 900.0); // 10 * 100
// Older launch should have lower score
let week_ago = now - chrono::Duration::days(5);
let score_week = FrecencyStore::calculate_frecency(10, week_ago);
assert!(score_week < score_today);
assert!(score_week > 600.0); // 10 * 70
// Much older launch
let month_ago = now - chrono::Duration::days(45);
let score_month = FrecencyStore::calculate_frecency(10, month_ago);
assert!(score_month < score_week);
}
#[test]
fn get_score_at_matches_get_score() {
let mut store = FrecencyStore {
data: FrecencyData {
version: 1,
entries: HashMap::new(),
},
path: PathBuf::from("/dev/null"),
dirty: false,
};
store.data.entries.insert(
"test".to_string(),
FrecencyEntry {
launch_count: 5,
last_launch: Utc::now(),
},
);
let now = Utc::now();
let score_at = store.get_score_at("test", now);
let score = store.get_score("test");
// Both should be very close (same timestamp, within rounding)
assert!((score_at - score).abs() < 1.0);
}
#[test]
fn test_launch_count_matters() {
let now = Utc::now();
let score_few = FrecencyStore::calculate_frecency(2, now);
let score_many = FrecencyStore::calculate_frecency(20, now);
assert!(score_many > score_few);
assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x
}
#[test]
fn record_launch_sets_dirty_without_saving() {
let mut store = FrecencyStore {
data: FrecencyData::default(),
path: PathBuf::from("/dev/null"),
dirty: false,
};
store.record_launch("test-item");
assert!(store.dirty, "record_launch should set dirty flag");
assert_eq!(store.data.entries["test-item"].launch_count, 1);
}
}