Skip to main content

scope_engine/
state.rs

1use crate::api::{PropagationResult, PropagationSource, Reference, ReviewEvent};
2use std::collections::HashSet;
3
4pub struct PropagationState {
5    pending: Vec<PropagationResult>,
6    seen: HashSet<String>,
7}
8
9impl Default for PropagationState {
10    fn default() -> Self {
11        Self::new()
12    }
13}
14
15impl PropagationState {
16    #[must_use]
17    pub fn new() -> Self {
18        Self {
19            pending: Vec::new(),
20            seen: HashSet::new(),
21        }
22    }
23
24    pub fn accumulate(&mut self, results: Vec<PropagationResult>) {
25        for r in results {
26            if self.seen.insert(r.selector.clone()) {
27                self.pending.push(r);
28            }
29        }
30    }
31
32    #[must_use]
33    pub const fn pending_count(&self) -> usize {
34        self.pending.len()
35    }
36
37    pub fn next_review(&mut self) -> Option<ReviewEvent> {
38        let r = self.pending.pop()?;
39        self.seen.remove(&r.selector);
40        match &r.source {
41            PropagationSource::Lsp => {
42                // LSP found precise references — build KnownReferences event
43                let references: Vec<Reference> = r
44                    .lsp_references
45                    .clone()
46                    .unwrap_or_default()
47                    .into_iter()
48                    .map(|(selector, line, context)| Reference {
49                        selector,
50                        line,
51                        context,
52                    })
53                    .collect();
54                Some(ReviewEvent::KnownReferences {
55                    modified_symbol: r.selector,
56                    change_summary: r.reason,
57                    references,
58                    file_snippet: r.file_snippet.clone().unwrap_or_default(),
59                })
60            }
61            PropagationSource::OpenEnded => {
62                // No LSP — build InvestigateImpact event
63                Some(ReviewEvent::InvestigateImpact {
64                    modified_symbol: r.selector,
65                    change_summary: r.reason,
66                    diff_summary: r.diff_summary.clone().unwrap_or_default(),
67                    file_snippet: r.file_snippet.clone().unwrap_or_default(),
68                    project_files: r.project_files.clone().unwrap_or_default(),
69                })
70            }
71        }
72    }
73
74    pub fn next_reviews(&mut self, limit: usize) -> Vec<ReviewEvent> {
75        (0..limit).filter_map(|_| self.next_review()).collect()
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn lsp_result(selector: &str, reason: &str) -> PropagationResult {
84        PropagationResult {
85            selector: selector.to_string(),
86            reason: reason.to_string(),
87            source: PropagationSource::Lsp,
88            lsp_references: Some(vec![]),
89            diff_summary: None,
90            file_snippet: None,
91            project_files: None,
92        }
93    }
94
95    fn open_result(selector: &str, reason: &str) -> PropagationResult {
96        PropagationResult {
97            selector: selector.to_string(),
98            reason: reason.to_string(),
99            source: PropagationSource::OpenEnded,
100            lsp_references: None,
101            diff_summary: Some("test diff".to_string()),
102            file_snippet: Some("fn foo() {}".to_string()),
103            project_files: Some(vec!["src/lib.rs".to_string()]),
104        }
105    }
106
107    #[test]
108    fn accumulate_deduplicates_selectors() {
109        let mut state = PropagationState::new();
110        state.accumulate(vec![
111            lsp_result("src/a.rs::fn foo", "modified"),
112            lsp_result("src/a.rs::fn foo", "modified again"),
113        ]);
114        assert_eq!(state.pending.len(), 1);
115    }
116
117    #[test]
118    fn accumulate_keeps_distinct_selectors() {
119        let mut state = PropagationState::new();
120        state.accumulate(vec![
121            lsp_result("src/a.rs::fn foo", "modified"),
122            lsp_result("src/b.rs::fn bar", "modified"),
123        ]);
124        assert_eq!(state.pending.len(), 2);
125    }
126
127    #[test]
128    fn next_review_lsp_produces_known_references() {
129        let mut state = PropagationState::new();
130        let mut r = lsp_result("src/a.rs::fn foo", "referenced");
131        r.lsp_references = Some(vec![(
132            "src/b.rs::fn bar".to_string(),
133            10,
134            "foo();".to_string(),
135        )]);
136        state.accumulate(vec![r]);
137        let event = state.next_review().unwrap();
138        match event {
139            ReviewEvent::KnownReferences {
140                modified_symbol,
141                references,
142                ..
143            } => {
144                assert_eq!(modified_symbol, "src/a.rs::fn foo");
145                assert_eq!(references.len(), 1);
146                assert_eq!(references[0].selector, "src/b.rs::fn bar");
147            }
148            ReviewEvent::InvestigateImpact { .. } => panic!("Expected KnownReferences variant"),
149        }
150    }
151
152    #[test]
153    fn next_review_open_ended_produces_investigate_impact() {
154        let mut state = PropagationState::new();
155        state.accumulate(vec![open_result("src/a.rs::fn foo", "modified")]);
156        let event = state.next_review().unwrap();
157        match event {
158            ReviewEvent::InvestigateImpact {
159                modified_symbol,
160                diff_summary,
161                project_files,
162                ..
163            } => {
164                assert_eq!(modified_symbol, "src/a.rs::fn foo");
165                assert_eq!(diff_summary, "test diff");
166                assert_eq!(project_files.len(), 1);
167            }
168            ReviewEvent::KnownReferences { .. } => panic!("Expected InvestigateImpact variant"),
169        }
170    }
171
172    #[test]
173    fn next_review_returns_none_when_empty() {
174        let mut state = PropagationState::new();
175        assert!(state.next_review().is_none());
176    }
177
178    #[test]
179    fn next_reviews_respects_limit_and_reports_remaining() {
180        let mut state = PropagationState::new();
181        state.accumulate(vec![
182            lsp_result("src/a.rs::fn foo", "first"),
183            lsp_result("src/b.rs::fn bar", "second"),
184            lsp_result("src/c.rs::fn baz", "third"),
185        ]);
186
187        let events = state.next_reviews(2);
188
189        assert_eq!(events.len(), 2);
190        assert_eq!(state.pending_count(), 1);
191        match &events[0] {
192            ReviewEvent::KnownReferences {
193                modified_symbol, ..
194            } => assert_eq!(modified_symbol, "src/c.rs::fn baz"),
195            ReviewEvent::InvestigateImpact { .. } => panic!("Expected KnownReferences variant"),
196        }
197        match &events[1] {
198            ReviewEvent::KnownReferences {
199                modified_symbol, ..
200            } => assert_eq!(modified_symbol, "src/b.rs::fn bar"),
201            ReviewEvent::InvestigateImpact { .. } => panic!("Expected KnownReferences variant"),
202        }
203    }
204
205    #[test]
206    fn next_review_pops_in_lifo_order() {
207        let mut state = PropagationState::new();
208        state.accumulate(vec![
209            lsp_result("src/a.rs::fn foo", "first"),
210            lsp_result("src/b.rs::fn bar", "second"),
211        ]);
212        let e1 = state.next_review().unwrap();
213        match e1 {
214            ReviewEvent::KnownReferences {
215                modified_symbol, ..
216            } => assert_eq!(modified_symbol, "src/b.rs::fn bar"),
217            ReviewEvent::InvestigateImpact { .. } => panic!(),
218        }
219        let e2 = state.next_review().unwrap();
220        match e2 {
221            ReviewEvent::KnownReferences {
222                modified_symbol, ..
223            } => assert_eq!(modified_symbol, "src/a.rs::fn foo"),
224            ReviewEvent::InvestigateImpact { .. } => panic!(),
225        }
226    }
227
228    #[test]
229    fn acknowledged_selector_can_be_queued_again() {
230        let mut state = PropagationState::new();
231
232        state.accumulate(vec![lsp_result("src/a.rs::fn foo", "first")]);
233        assert_eq!(state.pending_count(), 1);
234        let first = state.next_review().unwrap();
235        match first {
236            ReviewEvent::KnownReferences {
237                modified_symbol,
238                change_summary,
239                ..
240            } => {
241                assert_eq!(modified_symbol, "src/a.rs::fn foo");
242                assert_eq!(change_summary, "first");
243            }
244            ReviewEvent::InvestigateImpact { .. } => panic!("Expected KnownReferences variant"),
245        }
246
247        state.accumulate(vec![lsp_result("src/a.rs::fn foo", "second")]);
248        assert_eq!(state.pending_count(), 1);
249        let second = state.next_review().unwrap();
250        match second {
251            ReviewEvent::KnownReferences {
252                modified_symbol,
253                change_summary,
254                ..
255            } => {
256                assert_eq!(modified_symbol, "src/a.rs::fn foo");
257                assert_eq!(change_summary, "second");
258            }
259            ReviewEvent::InvestigateImpact { .. } => panic!("Expected KnownReferences variant"),
260        }
261    }
262
263    #[test]
264    fn mixed_sources_generate_correct_variants() {
265        let mut state = PropagationState::new();
266        state.accumulate(vec![
267            lsp_result("src/a.rs::fn foo", "lsp ref"),
268            open_result("src/b.rs::fn bar", "open ref"),
269        ]);
270        assert_eq!(state.pending.len(), 2);
271        // LIFO: first pop is the last pushed (OpenEnded)
272        let e1 = state.next_review().unwrap();
273        assert!(matches!(e1, ReviewEvent::InvestigateImpact { .. }));
274        let e2 = state.next_review().unwrap();
275        assert!(matches!(e2, ReviewEvent::KnownReferences { .. }));
276    }
277}