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