purple_ssh/app/
key_push_state.rs1use std::sync::Arc;
9use std::sync::atomic::AtomicBool;
10
11use ratatui::widgets::ListState;
12
13use crate::key_push::KeyPushResult;
14use std::collections::HashSet;
15
16#[derive(Default)]
18pub struct KeyPushState {
19 pub selected: HashSet<String>,
22 pub committed: Vec<String>,
28 pub list_state: ListState,
31 pub results: Vec<KeyPushResult>,
35 pub expected_count: usize,
38 pub cancel: Option<Arc<AtomicBool>>,
41 pub worker: Option<std::thread::JoinHandle<()>>,
43 pub run_id: u64,
47}
48
49impl KeyPushState {
50 pub fn shutdown(&mut self) {
54 if let Some(ref cancel) = self.cancel {
55 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
56 }
57 if let Some(handle) = self.worker.take() {
58 let _ = handle.join();
59 }
60 }
61
62 pub fn reset_picker(&mut self) {
66 self.selected.clear();
67 self.list_state.select(Some(0));
68 }
69
70 pub fn start_run(&mut self, expected: usize) -> (u64, Arc<AtomicBool>) {
77 self.results.clear();
78 self.expected_count = expected;
79 self.run_id = self.run_id.wrapping_add(1);
80 let cancel = Arc::new(AtomicBool::new(false));
81 self.cancel = Some(cancel.clone());
82 (self.run_id, cancel)
83 }
84
85 pub fn finish_run(&mut self) {
89 self.results.clear();
90 self.expected_count = 0;
91 self.selected.clear();
92 self.cancel = None;
93 if let Some(handle) = self.worker.take() {
94 let _ = handle.join();
95 }
96 }
97
98 pub fn cancel_run(&mut self) {
104 self.results.clear();
105 self.expected_count = 0;
106 self.cancel = None;
107 self.selected.clear();
108 self.run_id = self.run_id.wrapping_add(1);
109 }
110
111 pub fn clear_inflight_state(&mut self) {
117 self.cancel = None;
118 self.expected_count = 0;
119 self.worker = None;
120 }
121}
122
123#[cfg(test)]
124#[allow(clippy::field_reassign_with_default)]
125mod tests {
126 use super::*;
127 use std::sync::atomic::Ordering;
128
129 #[test]
130 fn default_is_empty() {
131 let s = KeyPushState::default();
132 assert!(s.selected.is_empty());
133 assert_eq!(s.list_state.selected(), None);
134 assert!(s.results.is_empty());
135 assert_eq!(s.expected_count, 0);
136 assert!(s.cancel.is_none());
137 assert!(s.worker.is_none());
138 }
139
140 #[test]
141 fn reset_picker_clears_selection_and_resets_cursor() {
142 let mut s = KeyPushState::default();
143 s.selected.insert("host-a".to_string());
144 s.selected.insert("host-b".to_string());
145 s.list_state.select(Some(5));
146 s.reset_picker();
147 assert!(s.selected.is_empty());
148 assert_eq!(s.list_state.selected(), Some(0));
149 }
150
151 #[test]
152 fn reset_picker_leaves_inflight_state_alone() {
153 let mut s = KeyPushState::default();
157 s.cancel = Some(Arc::new(AtomicBool::new(false)));
158 s.expected_count = 5;
159 s.results.push(crate::key_push::KeyPushResult {
160 alias: "h".into(),
161 outcome: crate::key_push::KeyPushOutcome::Appended,
162 });
163 s.reset_picker();
164 assert!(s.cancel.is_some());
165 assert_eq!(s.expected_count, 5);
166 assert_eq!(s.results.len(), 1);
167 }
168
169 #[test]
170 fn shutdown_sets_cancel_flag() {
171 let mut s = KeyPushState::default();
172 let flag = Arc::new(AtomicBool::new(false));
173 s.cancel = Some(flag.clone());
174 s.shutdown();
175 assert!(flag.load(Ordering::Relaxed));
176 }
177
178 #[test]
179 fn shutdown_joins_worker_and_takes_handle() {
180 let mut s = KeyPushState::default();
181 let flag = Arc::new(AtomicBool::new(false));
182 let cancel = flag.clone();
183 let handle = std::thread::spawn(move || {
185 while !cancel.load(Ordering::Relaxed) {
186 std::thread::sleep(std::time::Duration::from_millis(1));
187 }
188 });
189 s.cancel = Some(flag);
190 s.worker = Some(handle);
191 s.shutdown();
192 assert!(s.worker.is_none(), "worker handle should be taken");
193 }
194
195 #[test]
196 fn shutdown_is_idempotent_with_no_worker() {
197 let mut s = KeyPushState::default();
198 s.shutdown();
200 s.shutdown();
201 }
202
203 #[test]
204 fn start_run_clears_results_sets_expected_and_stores_cancel() {
205 let mut s = KeyPushState::default();
206 s.results.push(crate::key_push::KeyPushResult {
207 alias: "old".into(),
208 outcome: crate::key_push::KeyPushOutcome::Appended,
209 });
210
211 let (_run_id, cancel) = s.start_run(4);
212
213 assert!(s.results.is_empty());
214 assert_eq!(s.expected_count, 4);
215 assert!(s.cancel.is_some());
216 cancel.store(true, Ordering::Relaxed);
218 assert!(s.cancel.as_ref().unwrap().load(Ordering::Relaxed));
219 }
220
221 #[test]
222 fn start_run_bumps_run_id_and_returns_it() {
223 let mut s = KeyPushState {
224 run_id: 41,
225 ..Default::default()
226 };
227 let (run_id, _cancel) = s.start_run(1);
228 assert_eq!(run_id, 42);
229 assert_eq!(s.run_id, 42);
230 }
231
232 #[test]
233 fn start_run_preserves_picker_state_and_committed() {
234 let mut s = KeyPushState::default();
235 s.selected.insert("host-a".into());
236 s.committed = vec!["host-a".into(), "host-b".into()];
237 s.list_state.select(Some(2));
238
239 let _ = s.start_run(2);
240
241 assert!(s.selected.contains("host-a"));
242 assert_eq!(s.committed, vec!["host-a".to_string(), "host-b".into()]);
243 assert_eq!(s.list_state.selected(), Some(2));
244 }
245
246 #[test]
247 fn finish_run_clears_run_accumulators() {
248 let mut s = KeyPushState {
249 expected_count: 5,
250 cancel: Some(Arc::new(AtomicBool::new(false))),
251 ..Default::default()
252 };
253 s.selected.insert("h".into());
254 s.results.push(crate::key_push::KeyPushResult {
255 alias: "h".into(),
256 outcome: crate::key_push::KeyPushOutcome::Appended,
257 });
258
259 s.finish_run();
260
261 assert!(s.results.is_empty());
262 assert_eq!(s.expected_count, 0);
263 assert!(s.selected.is_empty());
264 assert!(s.cancel.is_none());
265 }
266
267 #[test]
268 fn finish_run_joins_worker_and_takes_handle() {
269 let mut s = KeyPushState::default();
270 let handle = std::thread::spawn(|| {});
271 s.worker = Some(handle);
272 s.finish_run();
273 assert!(s.worker.is_none(), "worker handle should be taken");
274 }
275
276 #[test]
277 fn finish_run_preserves_committed_and_list_state() {
278 let mut s = KeyPushState::default();
279 s.committed = vec!["host-a".into()];
280 s.list_state.select(Some(3));
281
282 s.finish_run();
283
284 assert_eq!(s.committed, vec!["host-a".to_string()]);
285 assert_eq!(s.list_state.selected(), Some(3));
286 }
287
288 #[test]
289 fn cancel_run_clears_accumulators_and_bumps_run_id() {
290 let mut s = KeyPushState {
291 expected_count: 3,
292 run_id: 10,
293 cancel: Some(Arc::new(AtomicBool::new(false))),
294 ..Default::default()
295 };
296 s.selected.insert("h".into());
297 s.results.push(crate::key_push::KeyPushResult {
298 alias: "h".into(),
299 outcome: crate::key_push::KeyPushOutcome::Appended,
300 });
301
302 s.cancel_run();
303
304 assert!(s.results.is_empty());
305 assert_eq!(s.expected_count, 0);
306 assert!(s.cancel.is_none());
307 assert!(s.selected.is_empty());
308 assert_eq!(s.run_id, 11);
309 }
310
311 #[test]
312 fn cancel_run_preserves_worker_handle() {
313 let mut s = KeyPushState::default();
316 let handle = std::thread::spawn(|| {});
317 s.worker = Some(handle);
318 s.cancel_run();
319 assert!(s.worker.is_some(), "cancel must not take the worker handle");
320 if let Some(h) = s.worker.take() {
322 let _ = h.join();
323 }
324 }
325
326 #[test]
327 fn clear_inflight_state_drops_cancel_expected_count_worker() {
328 let mut s = KeyPushState {
329 expected_count: 7,
330 cancel: Some(Arc::new(AtomicBool::new(false))),
331 ..Default::default()
332 };
333 let (tx, rx) = std::sync::mpsc::channel::<()>();
338 let handle = std::thread::spawn(move || {
339 let _ = rx.recv();
340 });
341 s.worker = Some(handle);
342
343 s.clear_inflight_state();
344
345 assert_eq!(s.expected_count, 0);
346 assert!(s.cancel.is_none());
347 assert!(s.worker.is_none());
348
349 drop(tx);
350 }
351
352 #[test]
353 fn clear_inflight_state_preserves_committed_run_id_and_results() {
354 let mut s = KeyPushState {
355 run_id: 9,
356 ..Default::default()
357 };
358 s.committed = vec!["host".into()];
359 s.results.push(crate::key_push::KeyPushResult {
360 alias: "host".into(),
361 outcome: crate::key_push::KeyPushOutcome::Appended,
362 });
363
364 s.clear_inflight_state();
365
366 assert_eq!(s.run_id, 9);
367 assert_eq!(s.committed, vec!["host".to_string()]);
368 assert_eq!(s.results.len(), 1);
369 }
370}