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