mecha10_cli/dev/
termination.rs1use anyhow::Result;
7use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex, OnceLock};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum TerminationIntent {
13 StopCurrent,
14 QuitAll,
15}
16
17pub fn classify_key(key: &KeyEvent) -> Option<TerminationIntent> {
19 if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('x')) {
20 return Some(TerminationIntent::QuitAll);
21 }
22
23 if key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('x')) {
24 return Some(TerminationIntent::StopCurrent);
25 }
26
27 None
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ScreenOutcome {
32 ReturnToParent,
33 QuitAll,
34 Continue,
35}
36
37static QUIT_SIGNAL_INSTALLED: OnceLock<Arc<AtomicBool>> = OnceLock::new();
38
39static QUIT_SIGNAL_INIT_LOCK: Mutex<()> = Mutex::new(());
40
41pub fn install_quit_signal() -> Result<Arc<AtomicBool>> {
44 if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
45 return Ok(existing.clone());
46 }
47
48 let _guard = QUIT_SIGNAL_INIT_LOCK
49 .lock()
50 .unwrap_or_else(|poisoned| poisoned.into_inner());
51
52 if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
53 return Ok(existing.clone());
54 }
55
56 let running = Arc::new(AtomicBool::new(true));
57 let r = running.clone();
58 ctrlc::set_handler(move || {
59 r.store(false, Ordering::SeqCst);
60 })?;
61
62 Ok(QUIT_SIGNAL_INSTALLED.get_or_init(|| running).clone())
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68 use crossterm::event::KeyEvent;
69
70 #[test]
71 fn plain_q_is_stop_current() {
72 let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
73 assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
74 }
75
76 #[test]
77 fn esc_is_stop_current() {
78 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
79 assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
80 }
81
82 #[test]
83 fn plain_x_without_control_modifier_is_stop_current() {
84 let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
85 assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
86 }
87
88 #[test]
89 fn ctrl_c_is_quit_all() {
90 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
91 assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
92 }
93
94 #[test]
95 fn ctrl_x_is_quit_all() {
96 let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
97 assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
98 }
99
100 #[test]
101 fn ctrl_shift_c_is_still_quit_all() {
102 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
103 assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
104 }
105
106 #[test]
107 fn space_is_not_a_termination_key() {
108 let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
109 assert_eq!(classify_key(&key), None);
110 }
111
112 #[test]
113 fn plain_c_without_control_modifier_is_not_a_termination_key() {
114 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
115 assert_eq!(classify_key(&key), None);
116 }
117
118 #[test]
119 fn ctrl_q_is_not_a_termination_key() {
120 let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
121 assert_eq!(classify_key(&key), None);
122 }
123
124 #[test]
125 fn unrelated_key_is_not_a_termination_key() {
126 let key = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE);
127 assert_eq!(classify_key(&key), None);
128 }
129
130 #[test]
131 fn install_quit_signal_returns_a_flag_initially_true() {
132 let running = install_quit_signal().expect("installing the quit signal must succeed");
133 assert!(running.load(Ordering::SeqCst));
134 }
135
136 #[test]
137 fn install_quit_signal_is_idempotent_across_repeated_calls() {
138 let first = install_quit_signal().expect("first install must succeed");
139 let second = install_quit_signal().expect("second install must not error");
140 assert!(Arc::ptr_eq(&first, &second));
141 }
142
143 #[test]
144 fn install_quit_signal_survives_concurrent_first_callers() {
145 let handles: Vec<_> = (0..16).map(|_| std::thread::spawn(install_quit_signal)).collect();
146
147 let flags: Vec<Arc<AtomicBool>> = handles
148 .into_iter()
149 .map(|handle| {
150 handle
151 .join()
152 .expect("thread must not panic")
153 .expect("install_quit_signal must not error")
154 })
155 .collect();
156
157 let first = &flags[0];
158 for flag in &flags[1..] {
159 assert!(
160 Arc::ptr_eq(first, flag),
161 "all concurrent callers must observe the same shared flag"
162 );
163 }
164 }
165}