1use std::collections::HashMap;
45use std::path::Path;
46use std::time::{Duration, SystemTime, UNIX_EPOCH};
47
48use serde::{Deserialize, Serialize};
49
50use crate::RoutingPayload;
51
52pub const DEFAULT_CAP_PER_NAMESPACE: usize = 1000;
56
57pub const DEFAULT_HALF_LIFE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
61
62pub const RECENCY_WEIGHT: f64 = 100.0;
66
67pub const FREQUENCY_WEIGHT: f64 = 10.0;
72
73pub type MruKey = (String, String);
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct MruEntry {
83 pub last_used: SystemTime,
84 pub use_count: u32,
85}
86
87impl MruEntry {
88 fn fresh(now: SystemTime) -> Self {
89 Self {
90 last_used: now,
91 use_count: 1,
92 }
93 }
94}
95
96#[derive(Debug)]
100pub struct PickerMruIndex {
101 entries: HashMap<MruKey, MruEntry>,
102 cap_per_namespace: usize,
103}
104
105impl Default for PickerMruIndex {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111impl PickerMruIndex {
112 pub fn new() -> Self {
113 Self::with_cap(DEFAULT_CAP_PER_NAMESPACE)
114 }
115
116 pub fn with_cap(cap_per_namespace: usize) -> Self {
117 Self {
118 entries: HashMap::new(),
119 cap_per_namespace,
120 }
121 }
122
123 pub fn record(&mut self, source_id: &str, identity: &str) {
129 self.record_at(source_id, identity, SystemTime::now());
130 }
131
132 pub fn record_at(&mut self, source_id: &str, identity: &str, now: SystemTime) {
136 let key = (source_id.to_string(), identity.to_string());
137 if let Some(entry) = self.entries.get_mut(&key) {
138 entry.last_used = now;
139 entry.use_count = entry.use_count.saturating_add(1);
140 return;
141 }
142 let namespace_size = self.entries.keys().filter(|(s, _)| s == source_id).count();
146 if namespace_size >= self.cap_per_namespace
147 && let Some(victim) = self.lowest_frecency_in_namespace(source_id, now)
148 {
149 self.entries.remove(&victim);
150 }
151 self.entries.insert(key, MruEntry::fresh(now));
152 }
153
154 pub fn lookup(&self, source_id: &str, identity: &str) -> Option<&MruEntry> {
158 self.entries
159 .get(&(source_id.to_string(), identity.to_string()))
160 }
161
162 pub fn frecency_bonus(
166 &self,
167 source_id: &str,
168 identity: &str,
169 now: SystemTime,
170 half_life: Duration,
171 ) -> f64 {
172 match self.lookup(source_id, identity) {
173 Some(entry) => bonus_of(entry, now, half_life),
174 None => 0.0,
175 }
176 }
177
178 pub fn len(&self) -> usize {
179 self.entries.len()
180 }
181
182 pub fn is_empty(&self) -> bool {
183 self.entries.is_empty()
184 }
185
186 pub fn clear(&mut self) {
190 self.entries.clear();
191 }
192
193 pub fn iter(&self) -> impl Iterator<Item = (&MruKey, &MruEntry)> + '_ {
196 self.entries.iter()
197 }
198
199 fn lowest_frecency_in_namespace(&self, source_id: &str, now: SystemTime) -> Option<MruKey> {
200 self.entries
201 .iter()
202 .filter(|(k, _)| k.0 == source_id)
203 .min_by(|(_, a), (_, b)| {
204 let ba = bonus_of(a, now, DEFAULT_HALF_LIFE);
205 let bb = bonus_of(b, now, DEFAULT_HALF_LIFE);
206 ba.partial_cmp(&bb).unwrap_or(std::cmp::Ordering::Equal)
207 })
208 .map(|(k, _)| k.clone())
209 }
210
211 pub fn save_to(&self, path: &Path) -> Result<(), MruPersistError> {
217 let persisted = self.to_persisted();
218 let bytes = bincode::serde::encode_to_vec(&persisted, bincode::config::standard())
219 .map_err(MruPersistError::Encode)?;
220 if let Some(parent) = path.parent() {
221 std::fs::create_dir_all(parent).map_err(MruPersistError::Io)?;
222 }
223 let tmp = path.with_extension("tmp");
224 std::fs::write(&tmp, &bytes).map_err(MruPersistError::Io)?;
225 std::fs::rename(&tmp, path).map_err(MruPersistError::Io)?;
226 Ok(())
227 }
228
229 pub fn load_from(path: &Path) -> Result<Option<Self>, MruPersistError> {
237 let bytes = match std::fs::read(path) {
238 Ok(b) => b,
239 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
240 Err(e) => return Err(MruPersistError::Io(e)),
241 };
242 let (persisted, _): (PersistedIndex, usize) =
243 bincode::serde::decode_from_slice(&bytes, bincode::config::standard())
244 .map_err(MruPersistError::Decode)?;
245 if persisted.version != PERSIST_VERSION {
246 return Err(MruPersistError::VersionMismatch {
247 expected: PERSIST_VERSION,
248 found: persisted.version,
249 });
250 }
251 Ok(Some(Self::from_persisted(persisted)))
252 }
253
254 fn to_persisted(&self) -> PersistedIndex {
255 PersistedIndex {
256 version: PERSIST_VERSION,
257 cap_per_namespace: self.cap_per_namespace as u32,
258 entries: self
259 .entries
260 .iter()
261 .map(|(k, e)| PersistedEntry {
262 source_id: k.0.clone(),
263 identity: k.1.clone(),
264 last_used_unix_seconds: e
265 .last_used
266 .duration_since(UNIX_EPOCH)
267 .map(|d| d.as_secs())
268 .unwrap_or(0),
269 use_count: e.use_count,
270 })
271 .collect(),
272 }
273 }
274
275 fn from_persisted(persisted: PersistedIndex) -> Self {
276 let entries: HashMap<MruKey, MruEntry> = persisted
277 .entries
278 .into_iter()
279 .map(|p| {
280 (
281 (p.source_id, p.identity),
282 MruEntry {
283 last_used: UNIX_EPOCH + Duration::from_secs(p.last_used_unix_seconds),
284 use_count: p.use_count,
285 },
286 )
287 })
288 .collect();
289 Self {
290 entries,
291 cap_per_namespace: persisted.cap_per_namespace as usize,
292 }
293 }
294}
295
296const PERSIST_VERSION: u32 = 1;
302
303#[derive(Debug, Serialize, Deserialize)]
304struct PersistedIndex {
305 version: u32,
306 cap_per_namespace: u32,
307 entries: Vec<PersistedEntry>,
308}
309
310#[derive(Debug, Serialize, Deserialize)]
311struct PersistedEntry {
312 source_id: String,
313 identity: String,
314 last_used_unix_seconds: u64,
318 use_count: u32,
319}
320
321#[derive(Debug)]
326pub enum MruPersistError {
327 Io(std::io::Error),
328 Encode(bincode::error::EncodeError),
329 Decode(bincode::error::DecodeError),
330 VersionMismatch { expected: u32, found: u32 },
331}
332
333impl std::fmt::Display for MruPersistError {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 match self {
336 Self::Io(e) => write!(f, "MRU index io error: {e}"),
337 Self::Encode(e) => write!(f, "MRU index encode error: {e}"),
338 Self::Decode(e) => write!(f, "MRU index decode error: {e}"),
339 Self::VersionMismatch { expected, found } => write!(
340 f,
341 "MRU index version mismatch: expected v{expected}, found v{found}"
342 ),
343 }
344 }
345}
346
347impl std::error::Error for MruPersistError {}
348
349pub fn default_persist_path() -> Option<std::path::PathBuf> {
357 let path = lattice_config::cache_home()?.join("picker-mru.bincode");
358 if let Some(legacy) = dirs::cache_dir().map(|d| d.join("lattice").join("picker-mru.bincode")) {
362 lattice_config::migrate_path(&legacy, &path);
363 }
364 Some(path)
365}
366
367pub fn bonus_of(entry: &MruEntry, now: SystemTime, half_life: Duration) -> f64 {
371 let age = now
372 .duration_since(entry.last_used)
373 .unwrap_or(Duration::ZERO);
374 let decay = (0.5_f64).powf(age.as_secs_f64() / half_life.as_secs_f64().max(1.0));
375 let recency = decay * RECENCY_WEIGHT;
376 let frequency = (entry.use_count as f64 + 1.0).ln() * FREQUENCY_WEIGHT;
377 recency + frequency
378}
379
380pub fn routing_identity(payload: &RoutingPayload) -> Option<String> {
391 match payload {
392 RoutingPayload::OpenFile { path } => Some(format!("file:{}", path.display())),
393 RoutingPayload::Buffer { id } => Some(format!("buf:{id}")),
394 RoutingPayload::InvokeCommand { id, .. } => Some(format!("cmd:{id}")),
395 RoutingPayload::PasteRegister { name } => Some(format!("reg:{name}")),
396 RoutingPayload::JumpToMark { name } => Some(format!("mark:{name}")),
397 RoutingPayload::ExpandSnippet { id } => Some(format!("snip:{id}")),
398 RoutingPayload::Colorscheme { name } => Some(format!("colorscheme:{name}")),
402 RoutingPayload::BranchBase { name } => Some(format!("branch-base:{name}")),
405 RoutingPayload::JumpInBuffer { .. }
410 | RoutingPayload::LspLocation { .. }
411 | RoutingPayload::LspCompletion { .. }
412 | RoutingPayload::LspCodeAction { .. }
413 | RoutingPayload::LspCodeLens { .. }
414 | RoutingPayload::ColorPresentation { .. }
415 | RoutingPayload::LspInstance { .. }
416 | RoutingPayload::AiSession { .. }
418 | RoutingPayload::ResolveDiff { .. }
420 | RoutingPayload::LoadCommandLine { .. }
423 | RoutingPayload::LoadSearchLine { .. }
425 | RoutingPayload::PaneHistoryEntry { .. }
430 | RoutingPayload::SuppliedValue { .. }
438 | RoutingPayload::FileLocation { .. }
441 | RoutingPayload::Create { .. }
445 | RoutingPayload::AcceptShowMessageAction { .. } => None,
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 #![allow(clippy::unwrap_used, clippy::panic)]
452 use super::*;
453 use std::path::PathBuf;
454
455 #[test]
456 fn resolve_diff_routing_has_no_mru_identity() {
457 assert_eq!(
460 routing_identity(&RoutingPayload::ResolveDiff {
461 primary: 42,
462 accept: true,
463 }),
464 None
465 );
466 }
467
468 #[test]
469 fn routing_identity_returns_some_for_stable_variants() {
470 let cases = [
471 (
472 RoutingPayload::OpenFile {
473 path: PathBuf::from("/tmp/foo.rs"),
474 },
475 Some("file:/tmp/foo.rs".to_string()),
476 ),
477 (RoutingPayload::Buffer { id: 7 }, Some("buf:7".to_string())),
478 (
479 RoutingPayload::InvokeCommand {
480 id: "ex:edit".into(),
481 args: lattice_grammar::args::Args::None,
482 },
483 Some("cmd:ex:edit".to_string()),
484 ),
485 (
486 RoutingPayload::PasteRegister { name: 'a' },
487 Some("reg:a".to_string()),
488 ),
489 (
490 RoutingPayload::JumpToMark { name: 'a' },
491 Some("mark:a".to_string()),
492 ),
493 ];
494 for (payload, expected) in cases {
495 assert_eq!(routing_identity(&payload), expected);
496 }
497 }
498
499 #[test]
500 fn routing_identity_returns_none_for_drift_variants() {
501 let cases = [
502 RoutingPayload::JumpInBuffer {
503 buffer_id: 1,
504 line: 0,
505 col: 0,
506 },
507 RoutingPayload::LspLocation {
508 path: PathBuf::from("/tmp/x"),
509 line: 0,
510 col: 0,
511 },
512 RoutingPayload::LspCompletion { index: 0 },
513 RoutingPayload::LspCodeAction { index: 0 },
514 ];
515 for payload in cases {
516 assert_eq!(routing_identity(&payload), None);
517 }
518 }
519
520 #[test]
521 fn record_then_lookup_round_trips() {
522 let mut mru = PickerMruIndex::new();
523 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
524 mru.record_at("files", "file:/tmp/a", now);
525 let entry = mru.lookup("files", "file:/tmp/a").unwrap();
526 assert_eq!(entry.use_count, 1);
527 assert_eq!(entry.last_used, now);
528 let later = now + Duration::from_secs(60);
530 mru.record_at("files", "file:/tmp/a", later);
531 let entry = mru.lookup("files", "file:/tmp/a").unwrap();
532 assert_eq!(entry.use_count, 2);
533 assert_eq!(entry.last_used, later);
534 }
535
536 #[test]
537 fn frecency_bonus_decays_with_age() {
538 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000);
539 let entry = MruEntry {
540 last_used: now,
541 use_count: 1,
542 };
543 let fresh = bonus_of(&entry, now, DEFAULT_HALF_LIFE);
544 let week_later = bonus_of(&entry, now + DEFAULT_HALF_LIFE, DEFAULT_HALF_LIFE);
545 let two_weeks_later = bonus_of(&entry, now + 2 * DEFAULT_HALF_LIFE, DEFAULT_HALF_LIFE);
546 assert!(fresh > week_later);
549 assert!(week_later > two_weeks_later);
550 assert!(fresh >= RECENCY_WEIGHT);
553 }
554
555 #[test]
556 fn frecency_bonus_is_zero_for_missing_entries() {
557 let mru = PickerMruIndex::new();
558 let bonus = mru.frecency_bonus(
559 "files",
560 "file:/tmp/missing",
561 SystemTime::now(),
562 DEFAULT_HALF_LIFE,
563 );
564 assert_eq!(bonus, 0.0);
565 }
566
567 #[test]
568 fn namespacing_keeps_source_buckets_separate() {
569 let mut mru = PickerMruIndex::new();
570 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000_000);
571 mru.record_at("files", "file:/tmp/a", now);
572 assert!(mru.lookup("files", "file:/tmp/a").is_some());
575 assert!(mru.lookup("buffers", "file:/tmp/a").is_none());
576 }
577
578 #[test]
579 fn cap_eviction_drops_lowest_frecency_in_namespace() {
580 let mut mru = PickerMruIndex::with_cap(2);
581 let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000_000);
582 mru.record_at("files", "a", t0);
586 mru.record_at("files", "b", t0 + Duration::from_secs(60));
587 assert_eq!(mru.len(), 2);
588 let later = t0 + 2 * DEFAULT_HALF_LIFE + Duration::from_secs(120);
590 mru.record_at("files", "c", later);
591 assert_eq!(mru.len(), 2);
592 assert!(mru.lookup("files", "a").is_none());
594 assert!(mru.lookup("files", "b").is_some());
595 assert!(mru.lookup("files", "c").is_some());
596 }
597
598 #[test]
599 fn cap_eviction_does_not_cross_namespaces() {
600 let mut mru = PickerMruIndex::with_cap(1);
601 let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000_000);
602 mru.record_at("files", "a", t0);
603 mru.record_at("commands", "ex:write", t0);
604 assert_eq!(mru.len(), 2);
607 assert!(mru.lookup("files", "a").is_some());
608 assert!(mru.lookup("commands", "ex:write").is_some());
609 }
610
611 #[test]
612 fn clear_drops_everything() {
613 let mut mru = PickerMruIndex::new();
614 mru.record("files", "x");
615 mru.record("commands", "y");
616 mru.clear();
617 assert!(mru.is_empty());
618 }
619
620 #[test]
626 fn persist_round_trip_preserves_entries() {
627 let tmp =
628 std::env::temp_dir().join(format!("lattice-mru-rt-{}.bincode", std::process::id()));
629 let _ = std::fs::remove_file(&tmp);
630 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(6_000_000);
631 let mut original = PickerMruIndex::new();
632 original.record_at("files", "file:/tmp/a", now);
633 original.record_at("files", "file:/tmp/a", now + Duration::from_secs(30));
634 original.record_at("commands", "cmd:ex:write", now);
635 original.save_to(&tmp).expect("save");
636 let loaded = PickerMruIndex::load_from(&tmp)
637 .expect("load")
638 .expect("file exists");
639 assert_eq!(loaded.len(), 2);
640 let a = loaded.lookup("files", "file:/tmp/a").expect("a");
641 assert_eq!(a.use_count, 2);
642 let w = loaded.lookup("commands", "cmd:ex:write").expect("w");
643 assert_eq!(w.use_count, 1);
644 let _ = std::fs::remove_file(&tmp);
645 }
646
647 #[test]
651 fn load_missing_file_returns_none() {
652 let tmp =
653 std::env::temp_dir().join(format!("lattice-mru-nope-{}.bincode", std::process::id()));
654 let _ = std::fs::remove_file(&tmp);
655 let result = PickerMruIndex::load_from(&tmp).expect("ok");
656 assert!(result.is_none());
657 }
658
659 #[test]
662 fn load_corrupt_file_returns_err() {
663 let tmp =
664 std::env::temp_dir().join(format!("lattice-mru-bad-{}.bincode", std::process::id()));
665 std::fs::write(&tmp, b"definitely not bincode").expect("write");
666 let err = PickerMruIndex::load_from(&tmp).unwrap_err();
667 assert!(matches!(err, MruPersistError::Decode(_)));
668 let _ = std::fs::remove_file(&tmp);
669 }
670
671 #[test]
675 fn save_atomicity_leaves_no_tmp_sidecar() {
676 let tmp =
677 std::env::temp_dir().join(format!("lattice-mru-atom-{}.bincode", std::process::id()));
678 let _ = std::fs::remove_file(&tmp);
679 let _ = std::fs::remove_file(tmp.with_extension("tmp"));
680 let mut mru = PickerMruIndex::new();
681 mru.record("files", "x");
682 mru.save_to(&tmp).expect("save");
683 assert!(tmp.exists());
684 assert!(!tmp.with_extension("tmp").exists());
685 let _ = std::fs::remove_file(&tmp);
686 }
687
688 #[test]
693 fn default_persist_path_targets_picker_mru_file() {
694 if let Some(path) = default_persist_path() {
695 assert_eq!(
696 path.file_name().and_then(|s| s.to_str()),
697 Some("picker-mru.bincode")
698 );
699 }
700 }
701}