Skip to main content

mecha10_cli/dev/
termination.rs

1//! Shared termination/intent model for `mecha10 dev`'s interactive TUI screens.
2//!
3//! Gives every screen a consistent answer for "what does this key mean" and installs
4//! a single shared Ctrl+C handler.
5
6use 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
17/// Classify a key event into a `TerminationIntent`, if it is one.
18pub 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
41/// Install a single process-wide Ctrl+C handler and return the shared "keep running" flag it
42/// clears on signal. Safe to call more than once; only the first call registers the handler.
43pub 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}