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 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 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 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}