Skip to main content

purple_ssh/app/
key_push_state.rs

1//! Key-push state. Tracks the picker selection set, in-flight worker
2//! handles and accumulated results between Screen transitions.
3//!
4//! Lives on `App` so the event loop can land per-host `KeyPushResult`
5//! events into `results` regardless of which Screen is active (the user
6//! may switch tabs while a push is running).
7
8use 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/// Push state owned by `App`. Empty between push runs.
17#[derive(Default)]
18pub struct KeyPushState {
19    /// Aliases the user has selected in the picker. Modified by Space
20    /// during `Screen::KeyPushPicker` and frozen into `committed` on Enter.
21    pub selected: HashSet<String>,
22    /// Snapshot of `selected` (in picker order) taken when the user
23    /// presses Enter to open `Screen::ConfirmKeyPush`. Read by the
24    /// confirm renderer and by `start_key_push`. Cleared on cancel or
25    /// after the worker spawns. Keeps `Screen::ConfirmKeyPush` payload
26    /// small.
27    pub committed: Vec<String>,
28    /// Cursor in the picker's host list. Indexes into the picker's
29    /// visible host slice.
30    pub list_state: ListState,
31    /// Results accumulated as `AppEvent::KeyPushResult` lands. Drained
32    /// when the run completes and the summary toast / sticky error is
33    /// rendered.
34    pub results: Vec<KeyPushResult>,
35    /// Total hosts the current run is targeting. Used to know when the
36    /// run is "done" so the summary can fire exactly once.
37    pub expected_count: usize,
38    /// Cancel flag observed by every worker thread. Set on push-cancel,
39    /// new push run, or App drop.
40    pub cancel: Option<Arc<AtomicBool>>,
41    /// JoinHandle for the worker pool. Joined on App drop.
42    pub worker: Option<std::thread::JoinHandle<()>>,
43    /// Monotonic run identifier. Bumped at the start of every push so
44    /// stale `KeyPushResult` events from a previously-cancelled run can
45    /// be dropped instead of contaminating the next run's accumulator.
46    pub run_id: u64,
47}
48
49impl KeyPushState {
50    /// Drop the worker handle gracefully. Called from `App::drop` so a
51    /// panicking unwind cannot leave the push thread running with a
52    /// dangling sender.
53    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    /// Reset picker-only state without touching in-flight worker. Called
63    /// before opening the picker for a new key so the previous run's
64    /// selection set does not bleed in.
65    pub fn reset_picker(&mut self) {
66        self.selected.clear();
67        self.list_state.select(Some(0));
68    }
69
70    /// Begin a new push run. Clears the result accumulator, sets the
71    /// expected host count, bumps the monotonic run_id (so any stale
72    /// KeyPushResult events from a cancelled previous run can be dropped),
73    /// constructs a fresh cancel flag and stores it on state. Returns the
74    /// new run_id together with the cancel handle so the spawned worker
75    /// can share it.
76    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    /// Completion path. The worker loop has finished naturally; clear the
86    /// accumulators and join the worker handle. Safe to call when the
87    /// worker has already exited.
88    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    /// User-cancel path. The cancel flag is dropped, accumulators are
99    /// cleared, and run_id is bumped so in-flight KeyPushResult events
100    /// from the cancelled worker arrive with a stale run_id and are
101    /// dropped. The worker handle is intentionally NOT joined here so
102    /// the UI does not block while the thread observes the cancel flag.
103    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    /// Failure recovery after a failed worker spawn. Drops the cancel
112    /// handle, zeroes the expected count, and clears the worker slot.
113    /// Distinct from `finish_run`: the worker handle is None here (spawn
114    /// failed), and the result accumulator is left intact for any caller
115    /// that may want to surface it in the error path.
116    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        // shutdown is the path that touches worker/cancel; reset_picker
154        // is the picker-open path and must not interfere with a run that
155        // is still finalising.
156        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        // A trivial worker that observes the cancel flag and exits.
184        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        // Should not panic when called on an empty state.
199        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        // Returned cancel arc points to the same flag stored on state.
217        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        // Cancel is async via the cancel flag; the worker thread drains
314        // itself. We must not block the UI on join here.
315        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        // Clean up for the test harness.
321        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        // Channel-based blocking thread so clear_inflight_state can drop
334        // the JoinHandle without leaving a detached thread spinning in
335        // the test harness. drop(tx) at the end signals the thread to
336        // exit cleanly via the rx disconnect.
337        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}