Skip to main content

lattice_picker/
mru.rs

1//! MRU (frecency) index for picker candidates.
2//!
3//! This module is the picker-owned, source-agnostic ranking
4//! signal. Sources never opt in or out -- the picker derives
5//! identity from each candidate's [`RoutingPayload`] via
6//! [`routing_identity`] and records a frecency entry against
7//! `(source_id, identity)` on every accept. Refilter combines
8//! the matcher's string score with the frecency bonus so
9//! recently-used candidates float to the top within a tier --
10//! mirroring vertico + prescient's "most-recent on top" UX
11//! that this design is modelled on.
12//!
13//! ## Identity derivation
14//!
15//! [`routing_identity`] is a pure function of [`RoutingPayload`].
16//! Variants with a stable identity (`OpenFile { path }`,
17//! `Buffer { id }`, `InvokeCommand { id }`, `PasteRegister`,
18//! `ExpandSnippet`, `JumpToMark`) return `Some(key)`; variants
19//! whose payload drifts with edits or is per-request
20//! (`JumpInBuffer`, `JumpToLocation`, `LspCompletion`,
21//! `LspCodeAction`) return `None`. The picker silently skips
22//! `None` candidates for both record + lookup -- those rows
23//! never participate in MRU.
24//!
25//! ## Frecency formula
26//!
27//! Recency-dominant, frequency tiebreaker:
28//!
29//! ```text
30//! decay = 0.5 ^ (age / half_life)
31//! bonus = decay * RECENCY_WEIGHT + ln(use_count + 1) * FREQUENCY_WEIGHT
32//! ```
33//!
34//! Numbers (`RECENCY_WEIGHT = 100.0`, `FREQUENCY_WEIGHT = 10.0`,
35//! `DEFAULT_HALF_LIFE = 7 days`) are tunable via typed options
36//! (slice 14c). The shape is fixed.
37//!
38//! ## Persistence
39//!
40//! Slice 14b adds `save_to` / `load_from` using bincode. This
41//! file holds the in-memory shape only; persistence is a thin
42//! wrapper around `entries` + a schema version byte.
43
44use std::collections::HashMap;
45use std::path::Path;
46use std::time::{Duration, SystemTime, UNIX_EPOCH};
47
48use serde::{Deserialize, Serialize};
49
50use crate::RoutingPayload;
51
52/// Default LRU cap per `(source_id, identity)` namespace.
53/// Above this, the lowest-frecency entry is evicted on each
54/// `record` call.
55pub const DEFAULT_CAP_PER_NAMESPACE: usize = 1000;
56
57/// Default half-life for the recency decay term. Tuned to
58/// "yesterday's choices still rank meaningfully; last week's
59/// fade out by half." `picker.mru.recency-half-life` overrides.
60pub const DEFAULT_HALF_LIFE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
61
62/// Recency contribution ceiling. The decay term `0.5^(age/HL)`
63/// is in `[0, 1]`; multiplied by this constant the recency
64/// bonus tops out at ~100 for a just-used entry.
65pub const RECENCY_WEIGHT: f64 = 100.0;
66
67/// Frequency contribution weight. `ln(use_count + 1) * 10`
68/// adds ~10 at use_count=1, ~23 at use_count=10, ~46 at
69/// use_count=100 -- a slow ramp that keeps frequent items
70/// sticky without overwhelming recency.
71pub const FREQUENCY_WEIGHT: f64 = 10.0;
72
73/// `(source_id, identity)` -- the index key. Source id
74/// namespaces the MRU so opening a file via `:picker files`
75/// and switching to it via `:picker buffers` count
76/// separately. Identity is derived from `RoutingPayload`.
77pub type MruKey = (String, String);
78
79/// One MRU record: when the candidate was last accepted and
80/// how many times total. Frecency combines both terms.
81#[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/// Picker-owned MRU index. Stored host-side as
97/// `Arc<RwLock<PickerMruIndex>>`; sources never touch it.
98/// `record` mutates; `lookup` is read-only.
99#[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    /// Record one accept of `identity` from source `source_id`.
124    /// If the key already exists, bumps `last_used` to `now` and
125    /// increments `use_count`. Otherwise creates a fresh entry,
126    /// evicting the lowest-frecency entry in the same source
127    /// namespace if at cap.
128    pub fn record(&mut self, source_id: &str, identity: &str) {
129        self.record_at(source_id, identity, SystemTime::now());
130    }
131
132    /// Like [`Self::record`] but with a caller-supplied `now`.
133    /// Test fixture; production callers use `record` so the
134    /// timestamp ordering is monotonic.
135    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        // Cap check: count only entries in the same source
143        // namespace so high-traffic sources (files) don't
144        // crowd out low-traffic ones (commands).
145        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    /// Look up the MRU entry for `(source_id, identity)`.
155    /// `None` means "never accepted" -- the candidate gets a
156    /// 0.0 bonus on score combine.
157    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    /// Compute the frecency bonus a candidate identified by
163    /// `(source_id, identity)` should receive when scored at
164    /// `now`. Returns 0.0 when there's no entry.
165    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    /// Drop every recorded entry. Used by tests + the
187    /// `picker.mru.persist = false` boot path that doesn't
188    /// load a prior cache.
189    pub fn clear(&mut self) {
190        self.entries.clear();
191    }
192
193    /// Iterate every entry. Used by persistence (slice 14b)
194    /// and `:describe-picker` introspection.
195    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    /// Encode + write the index to `path`. Atomic at the
212    /// filesystem level: writes to `<path>.tmp` first then
213    /// renames into place so a crash mid-write never leaves a
214    /// truncated cache. Errors surface as `Err(_)` for the
215    /// host to log + retry on the next accept.
216    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    /// Read + decode an index from `path`. Returns `Ok(None)`
230    /// when the file doesn't exist (fresh install). Returns
231    /// `Err` for IO or decode failures so the host can decide
232    /// whether to discard + start fresh or surface the error.
233    /// The default boot policy (slice 14c) is "discard +
234    /// start fresh" -- losing MRU is annoying, refusing to
235    /// boot is worse.
236    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
296/// Schema version stamped on the on-disk cache. Bump when
297/// the `PersistedIndex` shape changes incompatibly; loaders
298/// that see a different version surface
299/// `MruPersistError::VersionMismatch` and the host's boot
300/// policy discards + starts fresh.
301const 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    /// Seconds since UNIX epoch. SystemTime isn't directly
315    /// serde-serializable; this is the platform-neutral
316    /// substitute. Pre-1970 entries clamp to 0.
317    last_used_unix_seconds: u64,
318    use_count: u32,
319}
320
321/// Errors from MRU index persistence. The host (slice 14c)
322/// decides whether to discard + start fresh, retry, or surface
323/// to the user. Default policy: discard on `VersionMismatch` /
324/// `Decode`; log + retry on `Io` (write); never block boot.
325#[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
349/// Default path the host's boot uses for the MRU cache.
350/// `~/.config/lattice/cache/picker-mru.bincode` (honouring
351/// `$XDG_CONFIG_HOME`), under the config home like every
352/// other lattice path. Returns `None` when no config home
353/// resolves (e.g. sandboxed embedded runs); the host treats
354/// this as "persistence disabled" and runs MRU in-memory
355/// only.
356pub fn default_persist_path() -> Option<std::path::PathBuf> {
357    let path = lattice_config::cache_home()?.join("picker-mru.bincode");
358    // Once: carry the frecency index from the pre-0.9.2 `dirs::cache_dir()`
359    // location. Regenerable, but only by the user re-opening everything they
360    // had already taught it.
361    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
367/// Frecency bonus calculation. Pure function on the entry +
368/// reference time + decay half-life. Exposed for benches +
369/// the App-side snapshot pass.
370pub 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
380/// Derive the MRU identity for a routing payload. `None`
381/// means "no stable identity" -- the picker correctly skips
382/// MRU for these (LSP locations have drifting line/col,
383/// LSP code-action / completion indices are per-request).
384///
385/// **Plugin-custom routing** (Phase 7) returns `None` because
386/// the picker primitive doesn't know how to inspect the
387/// opaque bytes. Plugins that want MRU must emit one of the
388/// canonical variants; this is documented in
389/// `docs/dev/architecture/picker.md` § 8.
390pub 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        // T.12: theme names are stable identities, so the colorscheme
399        // picker gets MRU recency ranking (recently-applied themes
400        // float up).
401        RoutingPayload::Colorscheme { name } => Some(format!("colorscheme:{name}")),
402        // Branch names are stable identities, like theme names above —
403        // recently-used base branches float up.
404        RoutingPayload::BranchBase { name } => Some(format!("branch-base:{name}")),
405        // No stable identity for these -- coordinates drift,
406        // indices are per-request, LSP-instance entries are
407        // ephemeral, and show-message-request actions key into
408        // a per-request transient slot.
409        RoutingPayload::JumpInBuffer { .. }
410        | RoutingPayload::LspLocation { .. }
411        | RoutingPayload::LspCompletion { .. }
412        | RoutingPayload::LspCodeAction { .. }
413        | RoutingPayload::LspCodeLens { .. }
414        | RoutingPayload::ColorPresentation { .. }
415        | RoutingPayload::LspInstance { .. }
416        // AI sessions are ephemeral (start/stop), like LSP instances.
417        | RoutingPayload::AiSession { .. }
418        // Pending diffs are ephemeral (resolved + gone), so no MRU identity.
419        | RoutingPayload::ResolveDiff { .. }
420        // MB.3: the history ring is already recency-ordered, so a
421        // second MRU layer would double-rank; no stable identity.
422        | RoutingPayload::LoadCommandLine { .. }
423        // MB.5: search history ring, same as command history above.
424        | RoutingPayload::LoadSearchLine { .. }
425        // PBH.5: trail indices are per-pane and shift on every push or
426        // eviction, so they are not a stable identity. The trail is
427        // already ordered by recency anyway, exactly like the command
428        // and search rings above.
429        | RoutingPayload::PaneHistoryEntry { .. }
430        // MG.53.e: a supplied value has no meaning without knowing who
431        // asked for it. The same variant carries a file path for one
432        // argument and something else entirely for the next, so ranking
433        // them in one namespace would mix unrelated histories — a file
434        // picked for a stage argument would float to the top of a
435        // picker choosing something else. Per-consumer MRU needs the
436        // consumer's identity in the key, which is its own slice.
437        | RoutingPayload::SuppliedValue { .. }
438        // OR.6: a location is not an identity — the same line means something
439        // different after an edit above it.
440        | RoutingPayload::FileLocation { .. }
441        // OR.5: the create row's identity IS the query, which is a thing that
442        // has never been picked before by definition — recency over it would
443        // rank the user's typing rather than their history.
444        | 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        // Pending diffs are ephemeral (resolved + gone), so the diff-review
458        // picker rows must never accrue MRU recency.
459        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        // Re-record bumps use_count and last_used.
529        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        // Recency component halves each half-life; frequency
547        // (ln(2) * 10 ≈ 6.93) stays constant.
548        assert!(fresh > week_later);
549        assert!(week_later > two_weeks_later);
550        // Sanity: a same-instant entry gets ~RECENCY_WEIGHT
551        // worth of recency.
552        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        // Same identity under a different source is a distinct
573        // key -- the file-bucket vs buffer-bucket distinction.
574        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        // Two entries at t0 + t0+1m, then one at t0 + 2x_half_life
583        // -- the oldest decays the most and should evict on a
584        // fresh insert.
585        mru.record_at("files", "a", t0);
586        mru.record_at("files", "b", t0 + Duration::from_secs(60));
587        assert_eq!(mru.len(), 2);
588        // Triggers eviction in the `files` namespace.
589        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        // The oldest (`a`) should be gone.
593        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        // Each namespace is at cap; both entries survive
605        // because the cap is per-namespace, not global.
606        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    /// 14b: save + load round-trip preserves every entry's
621    /// source / identity / use_count, and a reload-then-bonus
622    /// computation matches what the original index returned.
623    /// Allows ±1s tolerance on `last_used` since the on-disk
624    /// shape is second-granularity.
625    #[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    /// 14b: a missing file (fresh install) returns `Ok(None)`
648    /// rather than `Err` so the boot path can fall through to
649    /// "start with an empty index" without special-casing.
650    #[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    /// 14b: a corrupt cache surfaces `Err(Decode)` so the
660    /// boot policy can discard + start fresh.
661    #[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    /// 14b: save uses a tmp-and-rename pattern so the cache
672    /// file is never truncated. After a successful save the
673    /// `.tmp` sidecar should be gone.
674    #[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    /// 14b: default_persist_path returns a path under the
689    /// platform's cache dir (when one exists). Not asserting
690    /// exact path because that varies per platform; just that
691    /// the file name is right.
692    #[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}