Skip to main content

ryo_suggest/
store.rs

1//! SuggestStore - Lifecycle management for suggestions
2//!
3//! Manages suggestion lifecycle in sync with AnalysisContext using
4//! HashMap-based storage with generation-based invalidation.
5
6use std::collections::HashMap;
7use std::time::Instant;
8
9use ryo_analysis::SymbolId;
10
11use crate::id::SuggestId;
12use crate::suggest::{compute_priority, OpportunityId, SafetyLevel, SuggestOpportunity};
13
14/// Precheck verification status
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum PrecheckStatus {
17    /// Not yet checked (default for non-precheck scans)
18    #[default]
19    NotChecked,
20    /// Precheck passed - mutation is safe to apply
21    Passed,
22    /// Precheck failed - mutation would cause errors
23    Failed,
24}
25
26/// Index into SuggestRegistry (newtype for type safety)
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct SuggestIndex(pub(crate) usize);
29
30impl SuggestIndex {
31    /// Get the underlying index value
32    pub fn as_usize(self) -> usize {
33        self.0
34    }
35}
36
37/// Internal storage for a suggestion
38#[derive(Debug)]
39pub struct StoredSuggestion {
40    /// The detected opportunity
41    pub opportunity: SuggestOpportunity,
42
43    /// Index into SuggestRegistry
44    pub suggest_idx: SuggestIndex,
45
46    /// Safety level (cached from Suggest)
47    pub safety: SafetyLevel,
48
49    /// Priority score (0-255, higher = more important)
50    /// Computed from: confidence * safety_weight
51    pub priority: u8,
52
53    /// Precheck verification status
54    pub precheck_status: PrecheckStatus,
55
56    /// Generation counter (incremented on symbol modification)
57    pub generation: u32,
58
59    /// Closed flag (user dismissed or superseded)
60    pub closed: bool,
61
62    /// Reason for closing (if closed)
63    pub close_reason: Option<String>,
64
65    /// Creation timestamp (for GC)
66    pub created_at: Instant,
67
68    /// Close timestamp (for GC)
69    pub closed_at: Option<Instant>,
70}
71
72impl StoredSuggestion {
73    /// Create a new stored suggestion
74    ///
75    /// # Arguments
76    /// - `opportunity`: The detected opportunity
77    /// - `suggest_idx`: Index into SuggestRegistry
78    /// - `safety`: Safety level from the Suggest implementation
79    /// - `pattern_weight`: Priority weight from `Suggest::priority_weight()`
80    pub fn new(
81        opportunity: SuggestOpportunity,
82        suggest_idx: SuggestIndex,
83        safety: SafetyLevel,
84        pattern_weight: f32,
85    ) -> Self {
86        let priority = compute_priority(opportunity.confidence, safety, pattern_weight);
87        Self {
88            opportunity,
89            suggest_idx,
90            safety,
91            priority,
92            precheck_status: PrecheckStatus::NotChecked,
93            generation: 0,
94            closed: false,
95            close_reason: None,
96            created_at: Instant::now(),
97            closed_at: None,
98        }
99    }
100
101    /// Create a stored suggestion with pre-computed priority
102    ///
103    /// Use this when priority has already been calculated (e.g., for sorting
104    /// before expensive precheck operations).
105    pub fn new_with_priority(
106        opportunity: SuggestOpportunity,
107        suggest_idx: SuggestIndex,
108        safety: SafetyLevel,
109        priority: u8,
110    ) -> Self {
111        Self {
112            opportunity,
113            suggest_idx,
114            safety,
115            priority,
116            precheck_status: PrecheckStatus::NotChecked,
117            generation: 0,
118            closed: false,
119            close_reason: None,
120            created_at: Instant::now(),
121            closed_at: None,
122        }
123    }
124
125    /// Mark this suggestion as closed
126    pub fn close(&mut self, reason: impl Into<String>) {
127        self.closed = true;
128        self.close_reason = Some(reason.into());
129        self.closed_at = Some(Instant::now());
130    }
131
132    /// Bump the generation (invalidates previous references)
133    pub fn bump_generation(&mut self) {
134        self.generation += 1;
135    }
136}
137
138/// Manages suggestion lifecycle in sync with AnalysisContext
139pub struct SuggestStore {
140    /// Primary storage: maps index to stored suggestion
141    suggestions: HashMap<u32, StoredSuggestion>,
142
143    /// Maps target symbol to suggestion indices (for invalidation)
144    symbol_to_suggests: HashMap<SymbolId, Vec<u32>>,
145
146    /// Dedup index: (pattern_index, opportunity_id) → suggestion index.
147    /// Prevents the same opportunity from being inserted multiple times
148    /// when detect() is called repeatedly (e.g., scan invoked multiple times).
149    dedup_index: HashMap<(SuggestIndex, OpportunityId), u32>,
150
151    /// Next index for SuggestId generation
152    next_index: u32,
153}
154
155impl Default for SuggestStore {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161impl SuggestStore {
162    /// Create a new empty store
163    pub fn new() -> Self {
164        Self {
165            suggestions: HashMap::new(),
166            symbol_to_suggests: HashMap::new(),
167            dedup_index: HashMap::new(),
168            next_index: 1, // Start at 1 for human-friendly IDs
169        }
170    }
171
172    /// Insert a new suggestion, returning its ID.
173    ///
174    /// Returns `None` if an active (non-closed) suggestion with the same
175    /// (pattern, opportunity_id) already exists (dedup).
176    pub fn insert(&mut self, suggestion: StoredSuggestion) -> Option<SuggestId> {
177        let dedup_key = (suggestion.suggest_idx, suggestion.opportunity.id);
178
179        // Check for existing active suggestion with same identity
180        if let Some(&existing_idx) = self.dedup_index.get(&dedup_key) {
181            if self
182                .suggestions
183                .get(&existing_idx)
184                .is_some_and(|s| !s.closed)
185            {
186                return None; // Active duplicate exists, skip
187            }
188        }
189
190        let index = self.next_index;
191        self.next_index += 1;
192
193        let generation = suggestion.generation;
194        let targets = suggestion.opportunity.targets.clone();
195
196        // Track symbol → suggestion mapping
197        for target in targets {
198            self.symbol_to_suggests
199                .entry(target)
200                .or_default()
201                .push(index);
202        }
203
204        self.dedup_index.insert(dedup_key, index);
205        self.suggestions.insert(index, suggestion);
206
207        Some(SuggestId::new(index, generation))
208    }
209
210    /// Get a suggestion by ID (returns None if invalid, stale, or closed)
211    pub fn get(&self, id: SuggestId) -> Option<&StoredSuggestion> {
212        self.suggestions
213            .get(&id.index())
214            .filter(|sug| sug.generation == id.generation() && !sug.closed)
215    }
216
217    /// Get a mutable reference to a suggestion by ID
218    pub fn get_mut(&mut self, id: SuggestId) -> Option<&mut StoredSuggestion> {
219        self.suggestions
220            .get_mut(&id.index())
221            .filter(|sug| sug.generation == id.generation() && !sug.closed)
222    }
223
224    /// Remove all suggestions for a deleted symbol
225    pub fn remove_for_symbol(&mut self, symbol: &SymbolId) {
226        if let Some(indices) = self.symbol_to_suggests.remove(symbol) {
227            for index in indices {
228                if let Some(removed) = self.suggestions.remove(&index) {
229                    let dedup_key = (removed.suggest_idx, removed.opportunity.id);
230                    self.dedup_index.remove(&dedup_key);
231                }
232            }
233        }
234    }
235
236    /// Invalidate (bump generation) for all suggestions targeting a modified symbol
237    pub fn invalidate_for_symbol(&mut self, symbol: &SymbolId) {
238        if let Some(indices) = self.symbol_to_suggests.get(symbol) {
239            for &index in indices {
240                if let Some(sug) = self.suggestions.get_mut(&index) {
241                    sug.bump_generation();
242                }
243            }
244        }
245    }
246
247    /// Check if a suggestion ID is still valid
248    pub fn is_valid(&self, id: SuggestId) -> bool {
249        self.get(id).is_some()
250    }
251
252    /// Get the current generation for a suggestion ID's index
253    pub fn current_generation(&self, id: SuggestId) -> Option<u32> {
254        self.suggestions.get(&id.index()).map(|s| s.generation)
255    }
256
257    /// Mark a suggestion as closed
258    pub fn close(&mut self, id: SuggestId, reason: impl Into<String>) -> bool {
259        if let Some(sug) = self.get_mut(id) {
260            sug.close(reason);
261            true
262        } else {
263            false
264        }
265    }
266
267    /// Iterate over all active suggestions
268    pub fn iter(&self) -> impl Iterator<Item = (SuggestId, &StoredSuggestion)> {
269        self.suggestions
270            .iter()
271            .filter(|(_, s)| !s.closed)
272            .map(|(&index, sug)| (SuggestId::new(index, sug.generation), sug))
273    }
274
275    /// Count active (non-closed) suggestions
276    pub fn len(&self) -> usize {
277        self.suggestions.iter().filter(|(_, s)| !s.closed).count()
278    }
279
280    /// Check if store is empty
281    pub fn is_empty(&self) -> bool {
282        self.len() == 0
283    }
284
285    /// Total number of suggestions (including closed)
286    pub fn total_count(&self) -> usize {
287        self.suggestions.len()
288    }
289
290    /// Clear all suggestions
291    pub fn clear(&mut self) {
292        self.suggestions.clear();
293        self.symbol_to_suggests.clear();
294        self.dedup_index.clear();
295        self.next_index = 1;
296    }
297}
298
299/// GC configuration for SuggestStore
300#[derive(Debug, Clone)]
301pub struct GcConfig {
302    /// How long to keep closed suggestions
303    pub max_closed_age: std::time::Duration,
304
305    /// Maximum number of suggestions before triggering GC
306    pub max_suggestions: usize,
307
308    /// GC interval
309    pub gc_interval: std::time::Duration,
310}
311
312impl Default for GcConfig {
313    fn default() -> Self {
314        Self {
315            max_closed_age: std::time::Duration::from_secs(300), // 5 minutes
316            max_suggestions: 1000,
317            gc_interval: std::time::Duration::from_secs(60), // 1 minute
318        }
319    }
320}
321
322impl SuggestStore {
323    /// Garbage collect old/stale suggestions
324    ///
325    /// Removes:
326    /// - Closed suggestions older than max_closed_age
327    /// - Suggestions with all targets removed
328    pub fn gc(&mut self, config: &GcConfig, valid_symbols: &impl Fn(&SymbolId) -> bool) {
329        let now = Instant::now();
330        let mut to_remove = Vec::new();
331
332        for (&index, sug) in self.suggestions.iter() {
333            // Remove old closed suggestions
334            if sug.closed {
335                if let Some(closed_at) = sug.closed_at {
336                    if now.duration_since(closed_at) > config.max_closed_age {
337                        to_remove.push(index);
338                        continue;
339                    }
340                }
341            }
342
343            // Remove suggestions where all targets are invalid
344            let any_valid = sug.opportunity.targets.iter().any(valid_symbols);
345            if !any_valid {
346                to_remove.push(index);
347            }
348        }
349
350        // Remove and clean up mappings
351        for index in to_remove {
352            if let Some(sug) = self.suggestions.remove(&index) {
353                let dedup_key = (sug.suggest_idx, sug.opportunity.id);
354                self.dedup_index.remove(&dedup_key);
355                for target in &sug.opportunity.targets {
356                    if let Some(indices) = self.symbol_to_suggests.get_mut(target) {
357                        indices.retain(|&i| i != index);
358                    }
359                }
360            }
361        }
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::suggest::{OpportunityContext, OpportunityId, SuggestLocation};
369
370    fn make_opportunity(id: u32, targets: Vec<SymbolId>) -> SuggestOpportunity {
371        SuggestOpportunity::new(
372            OpportunityId::new(id),
373            targets,
374            SuggestLocation::for_test("test.rs", "Test"),
375            "Test suggestion",
376            0.9,
377            OpportunityContext::Derive {
378                derive_name: "Default".into(),
379                missing_impls: vec![],
380            },
381        )
382    }
383
384    #[test]
385    fn test_suggest_id_format() {
386        let id = SuggestId::new(1, 0);
387        assert_eq!(id.to_string(), "S001g0");
388
389        let id2 = SuggestId::new(42, 3);
390        assert_eq!(id2.to_string(), "S042g3");
391    }
392
393    #[test]
394    fn test_suggest_id_parse() {
395        let id: SuggestId = "S001g0".parse().unwrap();
396        assert_eq!(id.index(), 1);
397        assert_eq!(id.generation(), 0);
398
399        let id2: SuggestId = "S042g3".parse().unwrap();
400        assert_eq!(id2.index(), 42);
401        assert_eq!(id2.generation(), 3);
402    }
403
404    #[test]
405    fn test_store_insert_and_get() {
406        let mut store = SuggestStore::new();
407        let sym = SymbolId::parse("100v1").unwrap();
408        let opp = make_opportunity(1, vec![sym]);
409        let sug = StoredSuggestion::new(opp, SuggestIndex(0), SafetyLevel::Auto, 1.0);
410
411        let id = store.insert(sug).expect("first insert should succeed");
412        assert_eq!(id.index(), 1);
413        assert_eq!(id.generation(), 0);
414
415        let retrieved = store.get(id).unwrap();
416        assert_eq!(retrieved.safety, SafetyLevel::Auto);
417    }
418
419    #[test]
420    fn test_store_invalidation() {
421        let mut store = SuggestStore::new();
422        let sym = SymbolId::parse("100v1").unwrap();
423        let opp = make_opportunity(1, vec![sym]);
424        let sug = StoredSuggestion::new(opp, SuggestIndex(0), SafetyLevel::Auto, 1.0);
425
426        let id = store.insert(sug).expect("insert should succeed");
427        assert!(store.is_valid(id));
428
429        // Invalidate
430        store.invalidate_for_symbol(&sym);
431
432        // Old ID is now invalid
433        assert!(!store.is_valid(id));
434
435        // New generation exists
436        let new_gen = store.current_generation(id).unwrap();
437        assert_eq!(new_gen, 1);
438    }
439
440    #[test]
441    fn test_store_close() {
442        let mut store = SuggestStore::new();
443        let sym = SymbolId::parse("100v1").unwrap();
444        let opp = make_opportunity(1, vec![sym]);
445        let sug = StoredSuggestion::new(opp, SuggestIndex(0), SafetyLevel::Auto, 1.0);
446
447        let id = store.insert(sug).expect("insert should succeed");
448        assert!(store.is_valid(id));
449        assert_eq!(store.len(), 1);
450
451        store.close(id, "Applied");
452        assert!(!store.is_valid(id));
453        assert_eq!(store.len(), 0);
454        assert_eq!(store.total_count(), 1); // Still stored for audit
455    }
456
457    #[test]
458    fn test_store_remove_for_symbol() {
459        let mut store = SuggestStore::new();
460        let sym1 = SymbolId::parse("100v1").unwrap();
461        let sym2 = SymbolId::parse("200v1").unwrap();
462
463        let opp1 = make_opportunity(1, vec![sym1]);
464        let opp2 = make_opportunity(2, vec![sym2]);
465
466        let sug1 = StoredSuggestion::new(opp1, SuggestIndex(0), SafetyLevel::Auto, 1.0);
467        let sug2 = StoredSuggestion::new(opp2, SuggestIndex(0), SafetyLevel::Auto, 1.0);
468
469        let id1 = store.insert(sug1).expect("insert sug1");
470        let id2 = store.insert(sug2).expect("insert sug2");
471
472        assert_eq!(store.len(), 2);
473
474        store.remove_for_symbol(&sym1);
475
476        assert!(!store.is_valid(id1));
477        assert!(store.is_valid(id2));
478        assert_eq!(store.len(), 1);
479    }
480
481    #[test]
482    fn test_store_iter() {
483        let mut store = SuggestStore::new();
484        let sym = SymbolId::parse("100v1").unwrap();
485
486        for i in 0..5 {
487            let opp = make_opportunity(i, vec![sym]);
488            let sug = StoredSuggestion::new(opp, SuggestIndex(0), SafetyLevel::Auto, 1.0);
489            store.insert(sug);
490        }
491
492        let ids: Vec<_> = store.iter().map(|(id, _)| id).collect();
493        assert_eq!(ids.len(), 5);
494    }
495}