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