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