1use std::sync::Arc;
9use std::sync::atomic::AtomicBool;
10
11use ratatui::widgets::ListState;
12
13use crate::key_push::{InflightChild, KeyPushResult};
14use std::collections::{HashSet, VecDeque};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum KeyPushPrompt {
22 Trust { alias: String, key_path: String },
24 Password { alias: String, key_path: String },
26}
27
28impl KeyPushPrompt {
29 pub fn alias(&self) -> &str {
30 match self {
31 KeyPushPrompt::Trust { alias, .. } | KeyPushPrompt::Password { alias, .. } => alias,
32 }
33 }
34
35 pub fn key_path(&self) -> &str {
38 match self {
39 KeyPushPrompt::Trust { key_path, .. } | KeyPushPrompt::Password { key_path, .. } => {
40 key_path
41 }
42 }
43 }
44}
45
46#[derive(Default)]
48pub struct KeyPushState {
49 pub selected: HashSet<String>,
52 pub committed: Vec<String>,
58 pub list_state: ListState,
61 pub results: Vec<KeyPushResult>,
65 pub expected_count: usize,
68 pub cancel: Option<Arc<AtomicBool>>,
71 pub worker: Option<std::thread::JoinHandle<()>>,
73 pub run_id: u64,
77 pub inflight: InflightChild,
81 pub key_path: String,
86 pub trust_new_host_key: bool,
90 pub pending_prompts: VecDeque<KeyPushPrompt>,
93}
94
95impl KeyPushState {
96 pub fn shutdown(&mut self) {
101 if let Some(ref cancel) = self.cancel {
102 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
103 }
104 self.inflight.terminate();
105 if let Some(handle) = self.worker.take() {
106 let _ = handle.join();
107 }
108 }
109
110 pub fn reset_picker(&mut self) {
114 self.selected.clear();
115 self.list_state.select(Some(0));
116 }
117
118 pub fn start_run(
125 &mut self,
126 expected: usize,
127 key_path: String,
128 trust_new_host_key: bool,
129 ) -> (u64, Arc<AtomicBool>) {
130 self.results.clear();
131 self.expected_count = expected;
132 self.key_path = key_path;
133 self.trust_new_host_key = trust_new_host_key;
134 self.run_id = self.run_id.wrapping_add(1);
135 let cancel = Arc::new(AtomicBool::new(false));
136 self.cancel = Some(cancel.clone());
137 self.inflight = InflightChild::default();
138 (self.run_id, cancel)
139 }
140
141 pub fn finish_run(&mut self) {
145 self.results.clear();
146 self.expected_count = 0;
147 self.selected.clear();
148 self.cancel = None;
149 if let Some(handle) = self.worker.take() {
150 let _ = handle.join();
151 }
152 }
153
154 pub fn cancel_run(&mut self) {
161 if let Some(ref cancel) = self.cancel {
162 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
163 }
164 self.inflight.terminate();
165 self.results.clear();
166 self.expected_count = 0;
167 self.cancel = None;
168 self.selected.clear();
169 self.pending_prompts.clear();
170 self.run_id = self.run_id.wrapping_add(1);
171 }
172
173 pub fn clear_inflight_state(&mut self) {
179 self.cancel = None;
180 self.expected_count = 0;
181 self.worker = None;
182 }
183}
184
185#[cfg(test)]
186#[allow(clippy::field_reassign_with_default)]
187mod tests {
188 use super::*;
189 use std::sync::atomic::Ordering;
190
191 #[test]
192 fn default_is_empty() {
193 let s = KeyPushState::default();
194 assert!(s.selected.is_empty());
195 assert_eq!(s.list_state.selected(), None);
196 assert!(s.results.is_empty());
197 assert_eq!(s.expected_count, 0);
198 assert!(s.cancel.is_none());
199 assert!(s.worker.is_none());
200 }
201
202 #[test]
203 fn reset_picker_clears_selection_and_resets_cursor() {
204 let mut s = KeyPushState::default();
205 s.selected.insert("host-a".to_string());
206 s.selected.insert("host-b".to_string());
207 s.list_state.select(Some(5));
208 s.reset_picker();
209 assert!(s.selected.is_empty());
210 assert_eq!(s.list_state.selected(), Some(0));
211 }
212
213 #[test]
214 fn reset_picker_leaves_inflight_state_alone() {
215 let mut s = KeyPushState::default();
219 s.cancel = Some(Arc::new(AtomicBool::new(false)));
220 s.expected_count = 5;
221 s.results.push(crate::key_push::KeyPushResult {
222 alias: "h".into(),
223 outcome: crate::key_push::KeyPushOutcome::Appended,
224 });
225 s.reset_picker();
226 assert!(s.cancel.is_some());
227 assert_eq!(s.expected_count, 5);
228 assert_eq!(s.results.len(), 1);
229 }
230
231 #[test]
232 fn shutdown_sets_cancel_flag() {
233 let mut s = KeyPushState::default();
234 let flag = Arc::new(AtomicBool::new(false));
235 s.cancel = Some(flag.clone());
236 s.shutdown();
237 assert!(flag.load(Ordering::Relaxed));
238 }
239
240 #[test]
241 fn shutdown_joins_worker_and_takes_handle() {
242 let mut s = KeyPushState::default();
243 let flag = Arc::new(AtomicBool::new(false));
244 let cancel = flag.clone();
245 let handle = std::thread::spawn(move || {
247 while !cancel.load(Ordering::Relaxed) {
248 std::thread::sleep(std::time::Duration::from_millis(1));
249 }
250 });
251 s.cancel = Some(flag);
252 s.worker = Some(handle);
253 s.shutdown();
254 assert!(s.worker.is_none(), "worker handle should be taken");
255 }
256
257 #[test]
258 fn shutdown_is_idempotent_with_no_worker() {
259 let mut s = KeyPushState::default();
260 s.shutdown();
262 s.shutdown();
263 }
264
265 #[test]
266 fn start_run_clears_results_sets_expected_and_stores_cancel() {
267 let mut s = KeyPushState::default();
268 s.results.push(crate::key_push::KeyPushResult {
269 alias: "old".into(),
270 outcome: crate::key_push::KeyPushOutcome::Appended,
271 });
272
273 let (_run_id, cancel) = s.start_run(4, "~/.ssh/id_a".into(), false);
274
275 assert!(s.results.is_empty());
276 assert_eq!(s.expected_count, 4);
277 assert!(s.cancel.is_some());
278 cancel.store(true, Ordering::Relaxed);
280 assert!(s.cancel.as_ref().unwrap().load(Ordering::Relaxed));
281 }
282
283 #[test]
284 fn start_run_bumps_run_id_and_returns_it() {
285 let mut s = KeyPushState {
286 run_id: 41,
287 ..Default::default()
288 };
289 let (run_id, _cancel) = s.start_run(1, "~/.ssh/id_a".into(), false);
290 assert_eq!(run_id, 42);
291 assert_eq!(s.run_id, 42);
292 }
293
294 #[test]
295 fn start_run_records_the_key_path_for_retries() {
296 let mut s = KeyPushState::default();
297 let _ = s.start_run(1, "~/.ssh/id_ed25519".into(), false);
298 assert_eq!(s.key_path, "~/.ssh/id_ed25519");
299 }
300
301 #[test]
302 fn start_run_records_and_resets_the_trust_flag() {
303 let mut s = KeyPushState::default();
304 let _ = s.start_run(1, "~/.ssh/id_a".into(), true);
305 assert!(s.trust_new_host_key, "a trust retry carries the flag");
306 let _ = s.start_run(1, "~/.ssh/id_a".into(), false);
307 assert!(!s.trust_new_host_key, "an ordinary push does not");
308 }
309
310 #[test]
311 fn start_run_preserves_picker_state_and_committed() {
312 let mut s = KeyPushState::default();
313 s.selected.insert("host-a".into());
314 s.committed = vec!["host-a".into(), "host-b".into()];
315 s.list_state.select(Some(2));
316
317 let _ = s.start_run(2, "~/.ssh/id_a".into(), false);
318
319 assert!(s.selected.contains("host-a"));
320 assert_eq!(s.committed, vec!["host-a".to_string(), "host-b".into()]);
321 assert_eq!(s.list_state.selected(), Some(2));
322 }
323
324 #[test]
325 fn start_run_keeps_pending_prompts_from_the_previous_run() {
326 let mut s = KeyPushState::default();
329 s.pending_prompts.push_back(KeyPushPrompt::Password {
330 alias: "h2".into(),
331 key_path: "~/.ssh/id_b".into(),
332 });
333 let _ = s.start_run(1, "~/.ssh/id_a".into(), false);
334 assert_eq!(s.pending_prompts.len(), 1);
335 }
336
337 #[test]
338 fn cancel_run_sets_the_flag_and_drops_pending_prompts() {
339 let mut s = KeyPushState::default();
340 let (_id, cancel) = s.start_run(2, "~/.ssh/id_a".into(), false);
341 s.pending_prompts.push_back(KeyPushPrompt::Trust {
342 alias: "h".into(),
343 key_path: "~/.ssh/id_a".into(),
344 });
345 s.cancel_run();
346 assert!(cancel.load(Ordering::Relaxed), "worker must see the cancel");
347 assert!(s.pending_prompts.is_empty());
348 }
349
350 #[test]
351 fn key_push_prompt_alias_names_the_host() {
352 let t = KeyPushPrompt::Trust {
353 alias: "a".into(),
354 key_path: "~/.ssh/id_a".into(),
355 };
356 assert_eq!(t.alias(), "a");
357 assert_eq!(t.key_path(), "~/.ssh/id_a");
358 let p = KeyPushPrompt::Password {
359 alias: "b".into(),
360 key_path: "~/.ssh/id_b".into(),
361 };
362 assert_eq!(p.alias(), "b");
363 assert_eq!(p.key_path(), "~/.ssh/id_b");
364 }
365
366 #[test]
367 fn finish_run_clears_run_accumulators() {
368 let mut s = KeyPushState {
369 expected_count: 5,
370 cancel: Some(Arc::new(AtomicBool::new(false))),
371 ..Default::default()
372 };
373 s.selected.insert("h".into());
374 s.results.push(crate::key_push::KeyPushResult {
375 alias: "h".into(),
376 outcome: crate::key_push::KeyPushOutcome::Appended,
377 });
378
379 s.finish_run();
380
381 assert!(s.results.is_empty());
382 assert_eq!(s.expected_count, 0);
383 assert!(s.selected.is_empty());
384 assert!(s.cancel.is_none());
385 }
386
387 #[test]
388 fn finish_run_joins_worker_and_takes_handle() {
389 let mut s = KeyPushState::default();
390 let handle = std::thread::spawn(|| {});
391 s.worker = Some(handle);
392 s.finish_run();
393 assert!(s.worker.is_none(), "worker handle should be taken");
394 }
395
396 #[test]
397 fn finish_run_preserves_committed_and_list_state() {
398 let mut s = KeyPushState::default();
399 s.committed = vec!["host-a".into()];
400 s.list_state.select(Some(3));
401
402 s.finish_run();
403
404 assert_eq!(s.committed, vec!["host-a".to_string()]);
405 assert_eq!(s.list_state.selected(), Some(3));
406 }
407
408 #[test]
409 fn cancel_run_clears_accumulators_and_bumps_run_id() {
410 let mut s = KeyPushState {
411 expected_count: 3,
412 run_id: 10,
413 cancel: Some(Arc::new(AtomicBool::new(false))),
414 ..Default::default()
415 };
416 s.selected.insert("h".into());
417 s.results.push(crate::key_push::KeyPushResult {
418 alias: "h".into(),
419 outcome: crate::key_push::KeyPushOutcome::Appended,
420 });
421
422 s.cancel_run();
423
424 assert!(s.results.is_empty());
425 assert_eq!(s.expected_count, 0);
426 assert!(s.cancel.is_none());
427 assert!(s.selected.is_empty());
428 assert_eq!(s.run_id, 11);
429 }
430
431 #[test]
432 fn cancel_run_preserves_worker_handle() {
433 let mut s = KeyPushState::default();
436 let handle = std::thread::spawn(|| {});
437 s.worker = Some(handle);
438 s.cancel_run();
439 assert!(s.worker.is_some(), "cancel must not take the worker handle");
440 if let Some(h) = s.worker.take() {
442 let _ = h.join();
443 }
444 }
445
446 #[test]
447 fn clear_inflight_state_drops_cancel_expected_count_worker() {
448 let mut s = KeyPushState {
449 expected_count: 7,
450 cancel: Some(Arc::new(AtomicBool::new(false))),
451 ..Default::default()
452 };
453 let (tx, rx) = std::sync::mpsc::channel::<()>();
458 let handle = std::thread::spawn(move || {
459 let _ = rx.recv();
460 });
461 s.worker = Some(handle);
462
463 s.clear_inflight_state();
464
465 assert_eq!(s.expected_count, 0);
466 assert!(s.cancel.is_none());
467 assert!(s.worker.is_none());
468
469 drop(tx);
470 }
471
472 #[test]
473 fn clear_inflight_state_preserves_committed_run_id_and_results() {
474 let mut s = KeyPushState {
475 run_id: 9,
476 ..Default::default()
477 };
478 s.committed = vec!["host".into()];
479 s.results.push(crate::key_push::KeyPushResult {
480 alias: "host".into(),
481 outcome: crate::key_push::KeyPushOutcome::Appended,
482 });
483
484 s.clear_inflight_state();
485
486 assert_eq!(s.run_id, 9);
487 assert_eq!(s.committed, vec!["host".to_string()]);
488 assert_eq!(s.results.len(), 1);
489 }
490}