Skip to main content

ryo_suggest/
trigger.rs

1//! SuggestTrigger and SuggestStrategy - Control re-evaluation timing
2//!
3//! For high-performance LLM-driven refactoring, we need fine-grained control
4//! over when Suggest re-evaluation occurs. Evaluating after every AC change
5//! would create a bottleneck.
6
7use std::collections::HashSet;
8use std::path::PathBuf;
9use std::time::{Duration, Instant};
10
11use ryo_analysis::SymbolId;
12use serde::{Deserialize, Serialize};
13
14/// Unique identifier for a Goal
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct GoalId(pub u64);
17
18impl GoalId {
19    pub fn new(id: u64) -> Self {
20        Self(id)
21    }
22}
23
24/// Unique identifier for a Wave (collection of Goals)
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub struct WaveId(pub u64);
27
28impl WaveId {
29    pub fn new(id: u64) -> Self {
30        Self(id)
31    }
32}
33
34/// Changes to AnalysisContext (used for incremental updates)
35#[derive(Debug, Clone, Default)]
36pub struct AcChanges {
37    /// Newly added symbols
38    pub added: Vec<SymbolId>,
39
40    /// Modified symbols (content changed)
41    pub modified: Vec<SymbolId>,
42
43    /// Removed symbols
44    pub removed: Vec<SymbolId>,
45}
46
47impl AcChanges {
48    /// Create empty changes
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Check if there are no changes
54    pub fn is_empty(&self) -> bool {
55        self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty()
56    }
57
58    /// Get all affected symbols (added + modified)
59    pub fn affected_symbols(&self) -> impl Iterator<Item = &SymbolId> {
60        self.added.iter().chain(self.modified.iter())
61    }
62
63    /// Total number of changes
64    pub fn len(&self) -> usize {
65        self.added.len() + self.modified.len() + self.removed.len()
66    }
67
68    /// Merge another set of changes into this one
69    pub fn merge(&mut self, other: AcChanges) {
70        self.added.extend(other.added);
71        self.modified.extend(other.modified);
72        self.removed.extend(other.removed);
73
74        // Remove duplicates
75        self.added.sort();
76        self.added.dedup();
77        self.modified.sort();
78        self.modified.dedup();
79        self.removed.sort();
80        self.removed.dedup();
81    }
82}
83
84/// Trigger for suggestion re-evaluation
85#[derive(Debug, Clone)]
86pub enum SuggestTrigger {
87    /// A single Goal completed (1 Goal = 1 logical unit of work)
88    GoalCompleted { goal_id: GoalId, changes: AcChanges },
89
90    /// A Wave completed (e.g., debug wave, feature wave)
91    /// Multiple Goals form a Wave
92    WaveCompleted {
93        wave_id: WaveId,
94        goal_count: usize,
95        changes: AcChanges,
96    },
97
98    /// Manual trigger (user requested refresh)
99    Manual,
100
101    /// Periodic timer (e.g., every 30 seconds idle)
102    Periodic { elapsed: Duration },
103
104    /// File watcher detected external changes
105    FileChanged { paths: Vec<PathBuf> },
106}
107
108impl SuggestTrigger {
109    /// Get the kind of this trigger (for strategy matching)
110    pub fn kind(&self) -> TriggerKind {
111        match self {
112            Self::GoalCompleted { .. } => TriggerKind::GoalCompleted,
113            Self::WaveCompleted { .. } => TriggerKind::WaveCompleted,
114            Self::Manual => TriggerKind::Manual,
115            Self::Periodic { .. } => TriggerKind::Periodic,
116            Self::FileChanged { .. } => TriggerKind::FileChanged,
117        }
118    }
119
120    /// Get the associated changes (if any)
121    pub fn changes(&self) -> Option<&AcChanges> {
122        match self {
123            Self::GoalCompleted { changes, .. } => Some(changes),
124            Self::WaveCompleted { changes, .. } => Some(changes),
125            _ => None,
126        }
127    }
128}
129
130/// Kind of trigger (for strategy configuration)
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
132pub enum TriggerKind {
133    GoalCompleted,
134    WaveCompleted,
135    Manual,
136    Periodic,
137    FileChanged,
138}
139
140/// Granularity of re-evaluation
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
142pub enum EvalGranularity {
143    /// Re-evaluate after every Goal (default for interactive)
144    #[default]
145    PerGoal,
146
147    /// Re-evaluate after every Wave (for debug/batch operations)
148    PerWave,
149
150    /// Re-evaluate after N goals
151    EveryNGoals(usize),
152
153    /// Re-evaluate only on manual trigger (maximum throughput)
154    ManualOnly,
155}
156
157/// Strategy for controlling suggestion re-evaluation timing
158#[derive(Debug, Clone)]
159pub struct SuggestStrategy {
160    /// Re-evaluation granularity
161    pub granularity: EvalGranularity,
162
163    /// Minimum interval between evaluations
164    pub min_interval: Duration,
165
166    /// Maximum pending changes before forcing evaluation
167    pub max_pending_changes: usize,
168
169    /// Enabled trigger kinds
170    pub enabled_triggers: HashSet<TriggerKind>,
171}
172
173impl Default for SuggestStrategy {
174    fn default() -> Self {
175        Self {
176            granularity: EvalGranularity::PerGoal,
177            min_interval: Duration::from_millis(100),
178            max_pending_changes: 50,
179            enabled_triggers: [
180                TriggerKind::GoalCompleted,
181                TriggerKind::WaveCompleted,
182                TriggerKind::Manual,
183            ]
184            .into_iter()
185            .collect(),
186        }
187    }
188}
189
190impl SuggestStrategy {
191    /// Interactive preset: evaluate after every goal
192    pub fn interactive() -> Self {
193        Self::default()
194    }
195
196    /// HighPerf preset: minimize re-evaluation overhead
197    pub fn high_perf() -> Self {
198        Self {
199            granularity: EvalGranularity::PerWave,
200            min_interval: Duration::from_millis(500),
201            max_pending_changes: 200,
202            enabled_triggers: [TriggerKind::WaveCompleted, TriggerKind::Manual]
203                .into_iter()
204                .collect(),
205        }
206    }
207
208    /// Batch preset: only manual evaluation
209    pub fn batch() -> Self {
210        Self {
211            granularity: EvalGranularity::ManualOnly,
212            min_interval: Duration::from_secs(1),
213            max_pending_changes: 1000,
214            enabled_triggers: [TriggerKind::Manual].into_iter().collect(),
215        }
216    }
217
218    /// Check if a trigger should cause re-evaluation
219    pub fn should_evaluate(&self, trigger: &SuggestTrigger, pending: &PendingChanges) -> bool {
220        let kind = trigger.kind();
221
222        // Check if trigger kind is enabled
223        if !self.enabled_triggers.contains(&kind) {
224            return false;
225        }
226
227        // Check minimum interval
228        if pending.last_eval.elapsed() < self.min_interval {
229            // Unless we've accumulated too many changes
230            if pending.changes.len() < self.max_pending_changes {
231                return false;
232            }
233        }
234
235        // Check granularity
236        match self.granularity {
237            EvalGranularity::PerGoal => {
238                matches!(trigger, SuggestTrigger::GoalCompleted { .. })
239                    || matches!(trigger, SuggestTrigger::Manual)
240            }
241            EvalGranularity::PerWave => {
242                matches!(trigger, SuggestTrigger::WaveCompleted { .. })
243                    || matches!(trigger, SuggestTrigger::Manual)
244            }
245            EvalGranularity::EveryNGoals(n) => {
246                pending.goal_count >= n || matches!(trigger, SuggestTrigger::Manual)
247            }
248            EvalGranularity::ManualOnly => matches!(trigger, SuggestTrigger::Manual),
249        }
250    }
251
252    /// Set granularity
253    pub fn with_granularity(mut self, granularity: EvalGranularity) -> Self {
254        self.granularity = granularity;
255        self
256    }
257
258    /// Set minimum interval
259    pub fn with_min_interval(mut self, interval: Duration) -> Self {
260        self.min_interval = interval;
261        self
262    }
263
264    /// Enable a trigger kind
265    pub fn enable_trigger(mut self, kind: TriggerKind) -> Self {
266        self.enabled_triggers.insert(kind);
267        self
268    }
269
270    /// Disable a trigger kind
271    pub fn disable_trigger(mut self, kind: TriggerKind) -> Self {
272        self.enabled_triggers.remove(&kind);
273        self
274    }
275}
276
277/// Accumulated changes waiting for evaluation
278#[derive(Debug)]
279pub struct PendingChanges {
280    /// Number of completed goals since last evaluation
281    pub goal_count: usize,
282
283    /// Accumulated changes
284    pub changes: AcChanges,
285
286    /// Last evaluation time
287    pub last_eval: Instant,
288}
289
290impl Default for PendingChanges {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296impl PendingChanges {
297    /// Create new pending changes tracker
298    pub fn new() -> Self {
299        Self {
300            goal_count: 0,
301            changes: AcChanges::default(),
302            last_eval: Instant::now(),
303        }
304    }
305
306    /// Record a goal completion
307    pub fn record_goal(&mut self, changes: AcChanges) {
308        self.goal_count += 1;
309        self.changes.merge(changes);
310    }
311
312    /// Take all pending changes and reset
313    pub fn take(&mut self) -> (usize, AcChanges) {
314        let goal_count = self.goal_count;
315        let changes = std::mem::take(&mut self.changes);
316        self.goal_count = 0;
317        self.last_eval = Instant::now();
318        (goal_count, changes)
319    }
320
321    /// Reset without taking changes
322    pub fn reset(&mut self) {
323        self.goal_count = 0;
324        self.changes = AcChanges::default();
325        self.last_eval = Instant::now();
326    }
327
328    /// Check if there are pending changes
329    pub fn has_pending(&self) -> bool {
330        self.goal_count > 0 || !self.changes.is_empty()
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_ac_changes_merge() {
340        let mut c1 = AcChanges {
341            added: vec![
342                SymbolId::parse("1v1").unwrap(),
343                SymbolId::parse("2v1").unwrap(),
344            ],
345            modified: vec![SymbolId::parse("3v1").unwrap()],
346            removed: vec![],
347        };
348
349        let c2 = AcChanges {
350            added: vec![
351                SymbolId::parse("2v1").unwrap(),
352                SymbolId::parse("4v1").unwrap(),
353            ],
354            modified: vec![
355                SymbolId::parse("3v1").unwrap(),
356                SymbolId::parse("5v1").unwrap(),
357            ],
358            removed: vec![SymbolId::parse("6v1").unwrap()],
359        };
360
361        c1.merge(c2);
362
363        assert_eq!(c1.added.len(), 3); // 1, 2, 4 (deduplicated)
364        assert_eq!(c1.modified.len(), 2); // 3, 5 (deduplicated)
365        assert_eq!(c1.removed.len(), 1); // 6
366    }
367
368    #[test]
369    fn test_strategy_per_goal() {
370        // Use min_interval of 0 for testing granularity logic
371        let strategy = SuggestStrategy::interactive().with_min_interval(Duration::ZERO);
372        let pending = PendingChanges::new();
373
374        let trigger = SuggestTrigger::GoalCompleted {
375            goal_id: GoalId::new(1),
376            changes: AcChanges::default(),
377        };
378
379        assert!(strategy.should_evaluate(&trigger, &pending));
380    }
381
382    #[test]
383    fn test_strategy_per_wave() {
384        // Use min_interval of 0 for testing granularity logic
385        let strategy = SuggestStrategy::high_perf().with_min_interval(Duration::ZERO);
386        let pending = PendingChanges::new();
387
388        // Goal trigger should not evaluate in PerWave mode (trigger kind not enabled)
389        let goal_trigger = SuggestTrigger::GoalCompleted {
390            goal_id: GoalId::new(1),
391            changes: AcChanges::default(),
392        };
393        assert!(!strategy.should_evaluate(&goal_trigger, &pending));
394
395        // Wave trigger should evaluate
396        let wave_trigger = SuggestTrigger::WaveCompleted {
397            wave_id: WaveId::new(1),
398            goal_count: 5,
399            changes: AcChanges::default(),
400        };
401        assert!(strategy.should_evaluate(&wave_trigger, &pending));
402    }
403
404    #[test]
405    fn test_strategy_manual_only() {
406        // Use min_interval of 0 for testing granularity logic
407        let strategy = SuggestStrategy::batch().with_min_interval(Duration::ZERO);
408        let pending = PendingChanges::new();
409
410        // Neither goal nor wave should evaluate (trigger kinds not enabled)
411        let goal_trigger = SuggestTrigger::GoalCompleted {
412            goal_id: GoalId::new(1),
413            changes: AcChanges::default(),
414        };
415        assert!(!strategy.should_evaluate(&goal_trigger, &pending));
416
417        // Manual should evaluate
418        assert!(strategy.should_evaluate(&SuggestTrigger::Manual, &pending));
419    }
420
421    #[test]
422    fn test_pending_changes() {
423        let mut pending = PendingChanges::new();
424
425        assert!(!pending.has_pending());
426
427        pending.record_goal(AcChanges {
428            added: vec![SymbolId::parse("1v1").unwrap()],
429            ..Default::default()
430        });
431
432        assert!(pending.has_pending());
433        assert_eq!(pending.goal_count, 1);
434
435        let (count, changes) = pending.take();
436        assert_eq!(count, 1);
437        assert_eq!(changes.added.len(), 1);
438        assert!(!pending.has_pending());
439    }
440}